diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f04c71c92ae..af787939bb2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -52,6 +52,7 @@ jobs: release wasm-sdk platform-wallet + wallet-storage swift-example-app kotlin-sdk kotlin-example-app diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index da128830869..825157ce0ed 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -153,6 +153,7 @@ jobs: --package platform-value \ --package rs-dapi \ --package platform-wallet \ + --package platform-wallet-storage \ --package rs-sdk-ffi \ --package platform-wallet-ffi \ --package rs-dapi-client \ @@ -317,6 +318,7 @@ jobs: --package platform-value \ --package rs-dapi \ --package platform-wallet \ + --package platform-wallet-storage \ --package rs-sdk-ffi \ --package platform-wallet-ffi \ --package rs-dapi-client \ diff --git a/Cargo.lock b/Cargo.lock index 8262a058813..123e637afa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert_cmd" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "assert_matches" version = "1.5.0" @@ -388,6 +403,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "barrel" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9e605929a6964efbec5ac0884bd0fe93f12a3b1eb271f52c251316640c68d9" + [[package]] name = "base16ct" version = "0.2.0" @@ -552,7 +573,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -561,6 +591,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitcoin-internals" version = "0.3.0" @@ -778,6 +814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -1132,7 +1169,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1876,6 +1913,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -2279,7 +2322,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2321,7 +2364,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex-automata", "regex-syntax", ] @@ -2368,6 +2411,16 @@ dependencies = [ "flate2", ] +[[package]] +name = "filetime" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2391,6 +2444,15 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2466,6 +2528,16 @@ dependencies = [ "futures-core", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2692,7 +2764,7 @@ dependencies = [ "memuse", "rand 0.8.6", "rand_core 0.6.4", - "rand_xorshift", + "rand_xorshift 0.3.0", "subtle", ] @@ -3311,7 +3383,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -3562,7 +3634,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4307,6 +4379,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4848,6 +4926,7 @@ dependencies = [ "key-wallet-manager", "platform-encryption", "rand 0.8.6", + "serde", "serde_json", "sha2", "static_assertions", @@ -4886,6 +4965,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "platform-wallet-storage" +version = "3.1.0-dev.1" +dependencies = [ + "assert_cmd", + "barrel", + "bincode", + "chrono", + "clap", + "dash-sdk", + "dashcore", + "dpp", + "filetime", + "fs2", + "hex", + "humantime", + "key-wallet", + "key-wallet-manager", + "platform-wallet", + "platform-wallet-storage", + "predicates", + "proptest", + "refinery", + "rusqlite", + "serde", + "serde_json", + "sha2", + "static_assertions", + "tempfile", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", +] + [[package]] name = "plotters" version = "0.3.7" @@ -4984,7 +5097,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", "predicates-core", + "regex", ] [[package]] @@ -5088,6 +5205,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.11.1", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift 0.4.0", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "prost" version = "0.13.5" @@ -5114,8 +5250,8 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", - "itertools 0.13.0", + "heck 0.5.0", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -5136,7 +5272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5149,7 +5285,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5254,6 +5390,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick_cache" version = "0.6.21" @@ -5279,7 +5421,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -5317,9 +5459,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5434,6 +5576,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -5528,6 +5679,47 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "refinery" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee5133e5b207e5703c2a4a9dc9bd8c8f2cc74c4ac04ca5510acaa907012c77ac" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a2a96d959c9b5b5da78e965bfdb1363b365bf5e84531a67d0eee827a702a3" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "rusqlite", + "siphasher", + "thiserror 2.0.18", + "time", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56c2e960c8e47c7c5c30ad334afea8b5502da796a59e34d640d6239d876d924" +dependencies = [ + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -6042,7 +6234,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6055,7 +6247,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6114,7 +6306,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6141,6 +6333,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -6954,7 +7158,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7764,6 +7968,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61faa33dc26b2851a37da5390a1a4cac015887b1e97ecd77ce7b4f987431de9f" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -7966,6 +8176,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -8356,7 +8575,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 078306a8b88..8288218b0ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ members = [ "packages/rs-dash-event-bus", "packages/rs-platform-wallet", "packages/rs-platform-wallet-ffi", + "packages/rs-platform-wallet-storage", "packages/rs-platform-encryption", "packages/wasm-sdk", "packages/rs-unified-sdk-ffi", diff --git a/Dockerfile b/Dockerfile index d4c787b7fc3..30cdef82cf9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -399,6 +399,7 @@ COPY --parents \ packages/rs-context-provider \ packages/rs-sdk-trusted-context-provider \ packages/rs-platform-wallet \ + packages/rs-platform-wallet-storage \ packages/wasm-dpp \ packages/wasm-dpp2 \ packages/wasm-drive-verify \ @@ -505,6 +506,7 @@ COPY --parents \ packages/rs-context-provider \ packages/rs-sdk-trusted-context-provider \ packages/rs-platform-wallet \ + packages/rs-platform-wallet-storage \ packages/wasm-dpp \ packages/wasm-dpp2 \ packages/wasm-drive-verify \ @@ -860,6 +862,7 @@ COPY --parents \ packages/rs-sdk-ffi \ packages/rs-unified-sdk-ffi \ packages/rs-platform-wallet \ + packages/rs-platform-wallet-storage \ packages/check-features \ packages/dash-platform-balance-checker \ packages/wasm-sdk \ diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml new file mode 100644 index 00000000000..9287d9ce689 --- /dev/null +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -0,0 +1,111 @@ +[package] +name = "platform-wallet-storage" +version.workspace = true +rust-version.workspace = true +edition = "2021" +authors = ["Dash Core Team"] +license = "MIT" +description = "Storage backends for platform-wallet: SQLite persistence (today) and a future SecretStore submodule" + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "platform-wallet-storage" +path = "src/bin/platform-wallet-storage.rs" +required-features = ["cli"] + +[dependencies] +# Cross-cutting deps (always on) +platform-wallet = { path = "../rs-platform-wallet", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +thiserror = "1" +tracing = "0.1" +hex = "0.4" + +# SQLite-backed persister deps (gated by the `sqlite` feature). +# `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. +key-wallet = { workspace = true, optional = true } +key-wallet-manager = { workspace = true, optional = true } +dashcore = { workspace = true, optional = true } +dpp = { path = "../rs-dpp", optional = true } +dash-sdk = { path = "../rs-sdk", default-features = false, features = [ + "dashpay-contract", + "dpns-contract", +], optional = true } +rusqlite = { version = "0.38", features = [ + "bundled", + "backup", + "blob", + "hooks", +], optional = true } +refinery = { version = "0.9", default-features = false, features = [ + "rusqlite", +], optional = true } +barrel = { version = "0.7", features = ["sqlite3"], optional = true } +# bincode 2 is required directly: we encode `dpp::IdentityPublicKey` +# (which derives bincode 2 `Encode`/`Decode`) and decode +# `dpp::AssetLockProof` from the asset-lock blob column. +bincode = { version = "2", optional = true } +fs2 = { version = "0.4", optional = true } +tempfile = { version = "3", optional = true } +chrono = { version = "0.4", default-features = false, features = [ + "clock", +], optional = true } +sha2 = { version = "0.10", optional = true } + +# CLI deps (gated by the `cli` feature) +clap = { version = "4", features = ["derive"], optional = true } +humantime = { version = "2", optional = true } +serde_json = { version = "1", optional = true } +tracing-subscriber = { version = "0.3", features = [ + "env-filter", +], optional = true } + +[dev-dependencies] +proptest = "1" +assert_cmd = "2" +predicates = "3" +static_assertions = "1" +filetime = "0.2" +platform-wallet-storage = { path = ".", features = ["sqlite", "cli", "test-helpers"] } + +[features] +default = ["sqlite", "cli"] +# SQLite-backed persister (`platform_wallet_storage::sqlite`). +sqlite = [ + "dep:key-wallet", + "dep:key-wallet-manager", + "dep:dashcore", + "dep:dpp", + "dep:dash-sdk", + "dep:rusqlite", + "dep:refinery", + "dep:barrel", + "dep:bincode", + "dep:fs2", + "dep:tempfile", + "dep:chrono", + "dep:sha2", +] +# Maintenance CLI binary. Requires `sqlite` because the only subcommands +# in scope today operate on the SQLite persister. +cli = [ + "sqlite", + "dep:clap", + "dep:humantime", + "dep:serde_json", + "dep:tracing-subscriber", +] +# Future `SecretStore` submodule. Slot is reserved; the module is not +# implemented in this build — enabling the feature today is a no-op +# beyond a `// pub mod secrets;` marker in `src/lib.rs`. +secrets = [] +# Exposes `lock_conn_for_test` / `config_for_test` accessors on +# `SqlitePersister` so this crate's own integration tests can probe the +# write connection. Downstream code MUST NOT enable this feature. +test-helpers = ["sqlite"] diff --git a/packages/rs-platform-wallet-storage/README.md b/packages/rs-platform-wallet-storage/README.md new file mode 100644 index 00000000000..3711fa5f8d8 --- /dev/null +++ b/packages/rs-platform-wallet-storage/README.md @@ -0,0 +1,92 @@ +# platform-wallet-storage + +Storage backends for the +[`platform-wallet`](../rs-platform-wallet) crate. Today this crate +ships a SQLite-backed implementation of `PlatformWalletPersistence` +under [`sqlite`](src/sqlite/) plus a maintenance CLI; the crate is +structured so a future `SecretStore` (currently sketched in +[`SECRETS.md`](./SECRETS.md)) can land as a sibling submodule under +[`secrets`](src/) without a crate split. + +## At a glance + +- One `.db` file holds many wallets — every per-wallet row carries a + `wallet_id BLOB` primary-key component. +- Schema migrations are append-only Rust files under `migrations/`, + applied via [`refinery`](https://github.com/rust-db/refinery) on every + `open`. +- Online backup uses `rusqlite::backup::Backup::run_to_completion` — + safe under a concurrent writer. +- **No private-key material.** See [`SECRETS.md`](./SECRETS.md). +- `Send + Sync`; usable behind `Arc`. + +## Library usage + +```rust,no_run +use std::sync::Arc; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +let config = SqlitePersisterConfig::new("/tmp/wallets.db"); +let persister: Arc = + Arc::new(SqlitePersister::open(config)?); +# Ok::<_, platform_wallet_storage::SqlitePersisterError>(()) +``` + +The same types are also reachable via their canonical submodule path — +`platform_wallet_storage::sqlite::SqlitePersister` — for callers that +want to be explicit about the backend. + +`SqlitePersisterConfig::new(path)` produces sensible defaults: +`Immediate` flush, 5 s busy timeout, WAL journal, `NORMAL` +synchronous, and an auto-backup dir at `/backups/auto/`. + +## CLI + +```text +platform-wallet-storage --db migrate [--no-auto-backup] +platform-wallet-storage --db backup --out +platform-wallet-storage --db restore --from --yes +platform-wallet-storage --db prune --in [--keep-last N] [--max-age 30d] +platform-wallet-storage --db inspect [--wallet-id ] [--format text|tsv|json] +platform-wallet-storage --db delete-wallet --wallet-id --yes [--no-auto-backup] +``` + +Destructive subcommands (`restore`, `delete-wallet`) REQUIRE `--yes` +— invoking them without it exits 2 with a usage error. `--no-auto-backup` +opts out of the pre-migration / pre-delete auto-backup respectively; +the library API has no equivalent opt-out (it routes to +[`SqlitePersister::delete_wallet_skip_backup`] internally). + +Logging: `-v` / `-vv` / `-vvv` enable `info` / `debug` / `trace` +respectively on stderr; `-q` suppresses non-error output. + +Exit codes: `0` success, `1` runtime error, `2` usage error, `3` +validation failure (e.g. corrupt backup source). + +## Cargo features + +| Feature | Default | What it brings | +|---|---|---| +| `sqlite` | yes | SQLite persister (`platform_wallet_storage::sqlite`) and all of its native deps (`rusqlite`, `refinery`, `dpp`, `dash-sdk`, `key-wallet`, etc.) | +| `cli` | yes | Maintenance binary `platform-wallet-storage`. Implies `sqlite`. | +| `secrets` | no | Reserved for the future `SecretStore` submodule. No code lands today. | +| `test-helpers` | no | Crate-private `lock_conn_for_test` / `config_for_test` accessors. Downstream MUST NOT enable. | + +`cargo build -p platform-wallet-storage --no-default-features` builds +the crate with neither the SQLite backend nor the CLI compiled in. +The resulting library has no public surface today; the build mode +exists to support a future split where one cargo target wants only +the secrets feature. + +## Schema + +See [`migrations/V001__initial.rs`](./migrations/V001__initial.rs) for +the canonical schema and +[`migrations/V002__defensive_update_triggers.rs`](./migrations/V002__defensive_update_triggers.rs) +for the `BEFORE UPDATE` FK-column guards. Foreign-key integrity is +emulated with triggers because barrel's column builder does not emit +composite-key `FK` clauses portably; INSERT, DELETE-cascade, and +UPDATE of `wallet_id` / `identity_id` are all covered. The result +matches native FKs for the persister's own write path, which never +mutates those columns directly. diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md new file mode 100644 index 00000000000..8871f0f3963 --- /dev/null +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -0,0 +1,67 @@ +# Private-key boundary + +The SQLite persister in `platform-wallet-storage::sqlite` is the +canonical persistence backend for the data carried by +`PlatformWalletPersistence` — UTXOs, identities, identity public keys, +contacts, asset locks, token balances, DashPay overlays, address-pool +snapshots. **None of that is secret material.** + +Mnemonics, seeds, raw private keys, and any other long-lived signing +material live exclusively on the client side (iOS Keychain, Android +Keystore, OS keyring, encrypted file vault). They are re-derived as +needed via the wallet's BIP-32/BIP-39 plumbing and never touch the +SQLite file the persister writes. + +## Future `secrets` submodule sketch + +This crate is structured so the `SecretStore` trait can land as a +submodule (`platform_wallet_storage::secrets`) gated behind a `secrets` +Cargo feature, sharing the crate-level error type and config +conventions. The module slot is reserved in `src/lib.rs` with a +commented-out `pub mod secrets;` line; the feature flag exists today +but flips no code. + +```rust +trait SecretStore: Send + Sync { + fn put(&self, wallet_id: WalletId, label: &str, bytes: &[u8]) -> Result<()>; + fn get(&self, wallet_id: WalletId, label: &str) -> Result>>; + fn delete(&self, wallet_id: WalletId, label: &str) -> Result<()>; +} +``` + +Reference backends to plan for: + +- `KeyringStore` (default) — OS-native keyring; recoverable across + reinstalls when the keyring is. +- `EncryptedFileStore` — Argon2id + XChaCha20-Poly1305 over a passphrase. +- `MemoryStore` — tests only. + +## What the SQLite backend WILL refuse to store + +The `identity_keys` table is for **public** material only — DPP +public keys, public-key hashes, optional DIP-9 derivation breadcrumbs. +If a sub-changeset ever gains a `private_key_bytes`-style field, the +trait conversation must reopen: the persister boundary stays +secret-free. + +## Audit hooks + +- **`tests/secrets_scan.rs`**: greps every file under + `src/sqlite/schema/` and `migrations/` for the substrings `private`, + `mnemonic`, `seed`, `xpriv`, `secret`. A new column, blob field, or + comment that uses any of those words breaks the test — forcing the + author to either rename, or add their phrase to the file's + allow-list with a rationale. The future `src/secrets/` directory is + exempt by design. +- NFR-4 / TC-082 (`tests/sqlite_persist_roundtrip.rs::tc082_no_box_dyn_error_in_src`): + all public method signatures use concrete error types + (`SqlitePersisterError`, `PersistenceError`) — never + `Box` — so a future leak is caught by `grep`. + +## Backup retention and secrets + +Manual / auto backups are byte-for-byte copies of the live DB. They +inherit the same "no secrets in the file" invariant. Operators may +still want to encrypt backups at rest using a file-system level tool +(GnuPG, age, encfs); this crate does not do that for them and never +ships SQLCipher. diff --git a/packages/rs-platform-wallet-storage/build.rs b/packages/rs-platform-wallet-storage/build.rs new file mode 100644 index 00000000000..34796d8f623 --- /dev/null +++ b/packages/rs-platform-wallet-storage/build.rs @@ -0,0 +1,21 @@ +//! Re-run the build whenever any file under `migrations/` changes. +//! +//! `refinery::embed_migrations!("./migrations")` is a proc-macro +//! evaluated at compile time. Cargo does not, by default, track +//! file-system reads inside proc macros — adding or editing a file +//! under `migrations/` will not trigger a rebuild of crates that +//! depend on this one until a source file in `src/` is touched. +//! Emitting `rerun-if-changed` directives below closes that gap. + +use std::path::Path; + +fn main() { + let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let migrations_dir = Path::new(&manifest).join("migrations"); + println!("cargo:rerun-if-changed={}", migrations_dir.display()); + if let Ok(entries) = std::fs::read_dir(&migrations_dir) { + for entry in entries.flatten() { + println!("cargo:rerun-if-changed={}", entry.path().display()); + } + } +} diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs new file mode 100644 index 00000000000..30f41bc44eb --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -0,0 +1,375 @@ +//! Initial schema for `platform-wallet-storage`. +//! +//! Built with `barrel` against the SQLite backend. Mirrors the table set +//! documented in the approved plan (`§"SQLite schema"`). +//! +//! Every per-wallet table carries `wallet_id BLOB` as part of (or all of) +//! the primary key, plus a `FOREIGN KEY (wallet_id) REFERENCES +//! wallet_metadata(wallet_id) ON DELETE CASCADE` so deleting the +//! metadata row drops the rest atomically. Foreign-key enforcement is +//! switched on per-connection by `SqlitePersister::open` via +//! `PRAGMA foreign_keys = ON`. + +use barrel::backend::Sqlite; +use barrel::{types, Migration}; + +pub fn migration() -> String { + let mut m = Migration::new(); + + // ------- wallet_metadata (parent) ------- + m.create_table("wallet_metadata", |t| { + t.add_column("wallet_id", types::binary().primary(true).nullable(false)); + t.add_column("network", types::text().nullable(false)); + t.add_column("birth_height", types::integer().nullable(false)); + }); + + // ------- account_registrations ------- + m.create_table("account_registrations", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("account_type", types::text().nullable(false)); + t.add_column("account_index", types::integer().nullable(false)); + t.add_column("account_xpub_bytes", types::binary().nullable(false)); + }); + + // ------- account_address_pools ------- + m.create_table("account_address_pools", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("account_type", types::text().nullable(false)); + t.add_column("account_index", types::integer().nullable(false)); + t.add_column("pool_type", types::text().nullable(false)); + t.add_column("snapshot_blob", types::binary().nullable(false)); + }); + + // ------- core_transactions ------- + m.create_table("core_transactions", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("txid", types::binary().nullable(false)); + t.add_column("height", types::integer().nullable(true)); + t.add_column("block_hash", types::binary().nullable(true)); + t.add_column("block_time", types::integer().nullable(true)); + t.add_column("finalized", types::boolean().nullable(false)); + t.add_column("record_blob", types::binary().nullable(false)); + }); + + // ------- core_utxos ------- + m.create_table("core_utxos", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("outpoint", types::binary().nullable(false)); + t.add_column("value", types::integer().nullable(false)); + t.add_column("script", types::binary().nullable(false)); + t.add_column("height", types::integer().nullable(true)); + t.add_column("account_index", types::integer().nullable(false)); + t.add_column("spent", types::boolean().nullable(false)); + t.add_column("spent_in_txid", types::binary().nullable(true)); + }); + + // ------- core_instant_locks ------- + m.create_table("core_instant_locks", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("txid", types::binary().nullable(false)); + t.add_column("islock_blob", types::binary().nullable(false)); + }); + + // ------- core_derived_addresses ------- + m.create_table("core_derived_addresses", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("account_type", types::text().nullable(false)); + t.add_column("address", types::text().nullable(false)); + t.add_column("derivation_path", types::text().nullable(false)); + t.add_column("used", types::boolean().nullable(false)); + }); + + // ------- core_sync_state (one row per wallet) ------- + m.create_table("core_sync_state", |t| { + t.add_column("wallet_id", types::binary().primary(true).nullable(false)); + t.add_column("last_processed_height", types::integer().nullable(true)); + t.add_column("synced_height", types::integer().nullable(true)); + }); + + // ------- identities ------- + m.create_table("identities", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("wallet_index", types::integer().nullable(true)); + t.add_column("identity_id", types::binary().nullable(false)); + t.add_column("entry_blob", types::binary().nullable(false)); + t.add_column("tombstoned", types::boolean().nullable(false)); + }); + + // ------- identity_keys ------- + m.create_table("identity_keys", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("identity_id", types::binary().nullable(false)); + t.add_column("key_id", types::integer().nullable(false)); + t.add_column("public_key_blob", types::binary().nullable(false)); + t.add_column("public_key_hash", types::binary().nullable(false)); + t.add_column("derivation_blob", types::binary().nullable(true)); + }); + + // ------- contacts_sent ------- + m.create_table("contacts_sent", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("owner_id", types::binary().nullable(false)); + t.add_column("recipient_id", types::binary().nullable(false)); + t.add_column("entry_blob", types::binary().nullable(false)); + }); + + // ------- contacts_recv ------- + m.create_table("contacts_recv", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("owner_id", types::binary().nullable(false)); + t.add_column("sender_id", types::binary().nullable(false)); + t.add_column("entry_blob", types::binary().nullable(false)); + }); + + // ------- contacts_established ------- + m.create_table("contacts_established", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("owner_id", types::binary().nullable(false)); + t.add_column("contact_id", types::binary().nullable(false)); + t.add_column("entry_blob", types::binary().nullable(false)); + }); + + // ------- platform_addresses ------- + m.create_table("platform_addresses", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("account_index", types::integer().nullable(false)); + t.add_column("address_index", types::integer().nullable(false)); + t.add_column("address", types::binary().nullable(false)); + t.add_column("balance", types::integer().nullable(false)); + t.add_column("nonce", types::integer().nullable(false)); + }); + + // ------- platform_address_sync (one row per wallet) ------- + m.create_table("platform_address_sync", |t| { + t.add_column("wallet_id", types::binary().primary(true).nullable(false)); + t.add_column("sync_height", types::integer().nullable(false)); + t.add_column("sync_timestamp", types::integer().nullable(false)); + t.add_column("last_known_recent_block", types::integer().nullable(false)); + }); + + // ------- asset_locks ------- + m.create_table("asset_locks", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("outpoint", types::binary().nullable(false)); + t.add_column("status", types::text().nullable(false)); + t.add_column("account_index", types::integer().nullable(false)); + t.add_column("identity_index", types::integer().nullable(false)); + t.add_column("amount_duffs", types::integer().nullable(false)); + t.add_column("lifecycle_blob", types::binary().nullable(false)); + }); + + // ------- token_balances ------- + m.create_table("token_balances", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("identity_id", types::binary().nullable(false)); + t.add_column("token_id", types::binary().nullable(false)); + t.add_column("balance", types::integer().nullable(false)); + t.add_column("updated_at", types::integer().nullable(false)); + }); + + // ------- dashpay_profiles ------- + m.create_table("dashpay_profiles", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("identity_id", types::binary().nullable(false)); + t.add_column("profile_blob", types::binary().nullable(false)); + }); + + // ------- dashpay_payments_overlay ------- + m.create_table("dashpay_payments_overlay", |t| { + t.add_column("wallet_id", types::binary().nullable(false)); + t.add_column("identity_id", types::binary().nullable(false)); + t.add_column("payment_id", types::text().nullable(false)); + t.add_column("overlay_blob", types::binary().nullable(false)); + }); + + // Barrel does NOT emit composite primary keys / FK clauses / indexes + // in a portable way for SQLite. Append raw DDL for the constraints + // and indexes the plan locks in. Composite PRIMARY KEYs require us + // to drop barrel's auto-rowid column policy: each `CREATE TABLE` + // above already produces a table; we layer the keys/FKs with + // statements barrel passes through verbatim via `inject_custom`. + let mut tail = String::new(); + tail.push_str( + "\nCREATE UNIQUE INDEX IF NOT EXISTS idx_account_registrations_pk \ + ON account_registrations(wallet_id, account_type, account_index);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_account_address_pools_pk \ + ON account_address_pools(wallet_id, account_type, account_index, pool_type);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_core_transactions_pk \ + ON core_transactions(wallet_id, txid);\n", + ); + tail.push_str( + "CREATE INDEX IF NOT EXISTS idx_core_transactions_height \ + ON core_transactions(wallet_id, height);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_core_utxos_pk \ + ON core_utxos(wallet_id, outpoint);\n", + ); + tail.push_str( + "CREATE INDEX IF NOT EXISTS idx_core_utxos_spent \ + ON core_utxos(wallet_id, spent);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_core_instant_locks_pk \ + ON core_instant_locks(wallet_id, txid);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_core_derived_addresses_pk \ + ON core_derived_addresses(wallet_id, account_type, address);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_identities_pk \ + ON identities(wallet_id, identity_id);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_keys_pk \ + ON identity_keys(wallet_id, identity_id, key_id);\n", + ); + tail.push_str( + "CREATE INDEX IF NOT EXISTS idx_identity_keys_wallet_identity \ + ON identity_keys(wallet_id, identity_id);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_sent_pk \ + ON contacts_sent(wallet_id, owner_id, recipient_id);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_recv_pk \ + ON contacts_recv(wallet_id, owner_id, sender_id);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_established_pk \ + ON contacts_established(wallet_id, owner_id, contact_id);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_platform_addresses_pk \ + ON platform_addresses(wallet_id, address);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_locks_pk \ + ON asset_locks(wallet_id, outpoint);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_token_balances_pk \ + ON token_balances(wallet_id, identity_id, token_id);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_dashpay_profiles_pk \ + ON dashpay_profiles(wallet_id, identity_id);\n", + ); + tail.push_str( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_dashpay_payments_overlay_pk \ + ON dashpay_payments_overlay(wallet_id, identity_id, payment_id);\n", + ); + + // Foreign-key + cascade rules. SQLite can't ALTER TABLE ADD + // CONSTRAINT, so we use TRIGGERS to emulate cascade on the + // wallet_metadata parent. Real FK enforcement requires `PRAGMA + // foreign_keys = ON` plus FK columns declared at CREATE TABLE + // time — which barrel's column builder doesn't expose. We + // therefore enforce parent integrity (no orphan inserts) via a + // BEFORE-INSERT trigger and cascade-delete via AFTER-DELETE + // triggers on `wallet_metadata`. + // + // The triggers are written so each `RAISE(ABORT, ...)` carries the + // canonical SQLite "FOREIGN KEY constraint failed" message that + // FR-11/TC-046 string-matches against. + let parent_check = |child: &str, _cols: &[&str]| -> String { + format!( + "CREATE TRIGGER IF NOT EXISTS fk_{child}_parent_insert \ + BEFORE INSERT ON {child} \ + FOR EACH ROW \ + WHEN (SELECT 1 FROM wallet_metadata WHERE wallet_id = NEW.wallet_id) IS NULL \ + BEGIN \ + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed'); \ + END;\n\ + CREATE TRIGGER IF NOT EXISTS fk_{child}_parent_update \ + BEFORE UPDATE ON {child} \ + FOR EACH ROW \ + WHEN (SELECT 1 FROM wallet_metadata WHERE wallet_id = NEW.wallet_id) IS NULL \ + BEGIN \ + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed'); \ + END;\n\ + CREATE TRIGGER IF NOT EXISTS cascade_{child}_on_wallet_delete \ + AFTER DELETE ON wallet_metadata \ + FOR EACH ROW \ + BEGIN \ + DELETE FROM {child} WHERE wallet_id = OLD.wallet_id; \ + END;\n" + ) + }; + for child in [ + "account_registrations", + "account_address_pools", + "core_transactions", + "core_utxos", + "core_instant_locks", + "core_derived_addresses", + "core_sync_state", + "identities", + "identity_keys", + "contacts_sent", + "contacts_recv", + "contacts_established", + "platform_addresses", + "platform_address_sync", + "asset_locks", + "token_balances", + "dashpay_profiles", + "dashpay_payments_overlay", + ] { + tail.push_str(&parent_check(child, &["wallet_id"])); + } + + // Identity-keys ⇄ identities and dashpay_profiles ⇄ identities: + // an identity_key row must reference an existing identities row. + tail.push_str( + "CREATE TRIGGER IF NOT EXISTS fk_identity_keys_parent_insert \ + BEFORE INSERT ON identity_keys \ + FOR EACH ROW \ + WHEN (SELECT 1 FROM identities WHERE wallet_id = NEW.wallet_id AND identity_id = NEW.identity_id) IS NULL \ + BEGIN \ + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed'); \ + END;\n\ + CREATE TRIGGER IF NOT EXISTS cascade_identity_keys_on_identity_delete \ + AFTER DELETE ON identities \ + FOR EACH ROW \ + BEGIN \ + DELETE FROM identity_keys WHERE wallet_id = OLD.wallet_id AND identity_id = OLD.identity_id; \ + END;\n", + ); + tail.push_str( + "CREATE TRIGGER IF NOT EXISTS fk_dashpay_profiles_parent_insert \ + BEFORE INSERT ON dashpay_profiles \ + FOR EACH ROW \ + WHEN (SELECT 1 FROM identities WHERE wallet_id = NEW.wallet_id AND identity_id = NEW.identity_id) IS NULL \ + BEGIN \ + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed'); \ + END;\n\ + CREATE TRIGGER IF NOT EXISTS cascade_dashpay_profiles_on_identity_delete \ + AFTER DELETE ON identities \ + FOR EACH ROW \ + BEGIN \ + DELETE FROM dashpay_profiles WHERE wallet_id = OLD.wallet_id AND identity_id = OLD.identity_id; \ + END;\n", + ); + + // `core_utxos.spent_in_txid` SET NULL on tx delete. + tail.push_str( + "CREATE TRIGGER IF NOT EXISTS setnull_core_utxos_on_tx_delete \ + AFTER DELETE ON core_transactions \ + FOR EACH ROW \ + BEGIN \ + UPDATE core_utxos SET spent_in_txid = NULL \ + WHERE wallet_id = OLD.wallet_id AND spent_in_txid = OLD.txid; \ + END;\n", + ); + + let mut sql = m.make::(); + sql.push_str(&tail); + sql +} diff --git a/packages/rs-platform-wallet-storage/migrations/V002__defensive_update_triggers.rs b/packages/rs-platform-wallet-storage/migrations/V002__defensive_update_triggers.rs new file mode 100644 index 00000000000..8d8fe31dd16 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V002__defensive_update_triggers.rs @@ -0,0 +1,50 @@ +//! Defensive `BEFORE UPDATE` triggers (SEC-003 from the Phase-2.8 +//! triage report). +//! +//! V001 emulates `INSERT` parent-existence checks and `AFTER DELETE` +//! cascade via triggers. It does NOT install `BEFORE UPDATE` triggers +//! on the parent's primary-key column or on the composite-FK column of +//! child tables. The persister's own write path never updates those +//! columns, but if a future migration accidentally introduces such an +//! UPDATE, the result is silent orphaning of child rows. +//! +//! This migration installs `BEFORE UPDATE OF wallet_id` triggers on +//! `wallet_metadata` and `BEFORE UPDATE OF identity_id` triggers on +//! `identity_keys` and `dashpay_profiles`. Each raises +//! `RAISE(ABORT, 'FOREIGN KEY constraint failed')` — the same idiom +//! V001 uses for the parent-existence check, so downstream string +//! matching stays stable. +//! +//! V001 remains untouched (append-only migration policy). + +pub fn migration() -> String { + let mut sql = String::new(); + sql.push_str( + "CREATE TRIGGER IF NOT EXISTS reject_wallet_metadata_id_update \ + BEFORE UPDATE OF wallet_id ON wallet_metadata \ + FOR EACH ROW \ + WHEN NEW.wallet_id IS NOT OLD.wallet_id \ + BEGIN \ + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed'); \ + END;\n", + ); + sql.push_str( + "CREATE TRIGGER IF NOT EXISTS reject_identity_keys_identity_id_update \ + BEFORE UPDATE OF identity_id ON identity_keys \ + FOR EACH ROW \ + WHEN NEW.identity_id IS NOT OLD.identity_id \ + BEGIN \ + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed'); \ + END;\n", + ); + sql.push_str( + "CREATE TRIGGER IF NOT EXISTS reject_dashpay_profiles_identity_id_update \ + BEFORE UPDATE OF identity_id ON dashpay_profiles \ + FOR EACH ROW \ + WHEN NEW.identity_id IS NOT OLD.identity_id \ + BEGIN \ + SELECT RAISE(ABORT, 'FOREIGN KEY constraint failed'); \ + END;\n", + ); + sql +} diff --git a/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs b/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs new file mode 100644 index 00000000000..d6be2e883cf --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs @@ -0,0 +1,442 @@ +//! CLI front-end for the SQLite persister. +//! +//! Output convention: stdout = data; stderr = diagnostics + error +//! messages (lower-cased, no trailing period, single line). + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::Duration; + +use clap::{Args, Parser, Subcommand}; + +use platform_wallet_storage::{ + AutoBackupOperation, RetentionPolicy, SqlitePersister, SqlitePersisterConfig, + WalletStorageError, +}; + +#[derive(Debug, Parser)] +#[command( + name = "platform-wallet-storage", + version, + about = "Maintenance CLI for the SQLite-backed platform wallet persister" +)] +struct Cli { + /// Path to the SQLite database file. + #[arg(long, value_name = "PATH", global = true)] + db: Option, + /// Auto-backup directory. Pass empty string to disable. + #[arg(long, value_name = "PATH", global = true)] + auto_backup_dir: Option, + /// Increase log verbosity (stderr). Repeat for more: `-v` enables + /// `info`, `-vv` enables `debug`, `-vvv` enables `trace`. + #[arg(long, short, global = true, action = clap::ArgAction::Count)] + verbose: u8, + /// Suppress non-error stderr output (overrides `--verbose`). + #[arg(long, short, global = true)] + quiet: bool, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Debug, Subcommand)] +enum Cmd { + /// Run migrations only (auto-backs-up by default). + Migrate(MigrateArgs), + /// Online backup to a timestamped `.db` file (or explicit path). + Backup(BackupArgs), + /// Replace --db with the contents of a backup. + Restore(RestoreArgs), + /// Apply retention to a backup directory. + Prune(PruneArgs), + /// Dump per-table row counts. + Inspect(InspectArgs), + /// Drop a wallet (auto-backs-up by default). + DeleteWallet(DeleteWalletArgs), +} + +#[derive(Debug, Args)] +struct MigrateArgs { + #[arg(long)] + no_auto_backup: bool, +} + +#[derive(Debug, Args)] +struct BackupArgs { + /// Output directory OR full file path. + #[arg(long, value_name = "PATH")] + out: PathBuf, +} + +#[derive(Debug, Args)] +struct RestoreArgs { + #[arg(long, value_name = "PATH")] + from: PathBuf, + #[arg(long)] + yes: bool, + /// Skip the pre-restore auto-backup of the live destination DB. + /// Without this, the persister writes `pre-restore-.db` to + /// `--auto-backup-dir` before clobbering the destination. + #[arg(long)] + no_auto_backup: bool, +} + +#[derive(Debug, Args)] +struct PruneArgs { + #[arg(long = "in", value_name = "DIR")] + in_dir: PathBuf, + #[arg(long)] + keep_last: Option, + #[arg(long, value_parser = parse_duration)] + max_age: Option, +} + +#[derive(Debug, Args)] +struct InspectArgs { + #[arg(long)] + wallet_id: Option, + #[arg(long, default_value = "text")] + format: InspectFormat, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +enum InspectFormat { + Text, + Tsv, + Json, +} + +#[derive(Debug, Args)] +struct DeleteWalletArgs { + #[arg(long)] + wallet_id: String, + #[arg(long)] + yes: bool, + #[arg(long)] + no_auto_backup: bool, +} + +fn parse_duration(s: &str) -> Result { + humantime::parse_duration(s).map_err(|e| format!("invalid duration `{s}`: {e}")) +} + +fn parse_wallet_id(s: &str) -> Result<[u8; 32], String> { + if s.len() != 64 { + return Err(format!( + "wallet id must be 64 hex characters, got {} (`{}`)", + s.len(), + s + )); + } + let bytes = hex::decode(s).map_err(|e| format!("wallet id is not valid hex: {e}"))?; + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + init_tracing(cli.verbose, cli.quiet); + match run(cli) { + Ok(code) => code, + Err(err) => { + eprintln!("error: {}", err.message); + err.code + } + } +} + +fn init_tracing(verbose: u8, quiet: bool) { + use tracing_subscriber::EnvFilter; + let level = if quiet { + "error" + } else { + match verbose { + 0 => "warn", + 1 => "info", + 2 => "debug", + _ => "trace", + } + }; + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(format!("platform_wallet_storage={level}"))); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .try_init(); +} + +struct CliError { + message: String, + code: ExitCode, +} + +impl CliError { + fn runtime(msg: impl Into) -> Self { + Self { + message: msg.into(), + code: ExitCode::from(1), + } + } + fn validation(msg: impl Into) -> Self { + Self { + message: msg.into(), + code: ExitCode::from(3), + } + } +} + +fn run(cli: Cli) -> Result { + let db = cli + .db + .ok_or_else(|| CliError::runtime("--db is required"))?; + let auto_backup_dir = match cli.auto_backup_dir { + None => None, + Some(s) if s.is_empty() => Some(None), + Some(s) => Some(Some(PathBuf::from(s))), + }; + + // For `prune`, we don't open a persister — pure filesystem op. + if let Cmd::Prune(args) = &cli.cmd { + return run_prune(args); + } + + // `restore` is an associated function; no persister needed beforehand. + if let Cmd::Restore(args) = &cli.cmd { + return run_restore(&db, args, auto_backup_dir.as_ref()); + } + + // For `migrate --no-auto-backup`, we must keep `auto_backup_dir = + // None` so the open-time pre-migration backup is skipped. For + // every other subcommand we leave the user-configured dir (or the + // default) in place — the library's safe-by-default semantics + // still apply. `delete-wallet --no-auto-backup` reaches a separate + // library entry point (`delete_wallet_skip_backup`) and so does + // not need the config to be mutated. + let mut config = SqlitePersisterConfig::new(&db); + if let Some(dir_opt) = auto_backup_dir.clone() { + config = config.with_auto_backup_dir(dir_opt); + } + if let Cmd::Migrate(m) = &cli.cmd { + if matches!(&auto_backup_dir, Some(None)) && !m.no_auto_backup { + return Err(CliError { + message: "auto-backup directory not configured; pass --no-auto-backup to proceed" + .to_string(), + code: ExitCode::from(1), + }); + } + if m.no_auto_backup { + config = config.with_auto_backup_dir(None); + eprintln!("warning: auto-backup skipped (--no-auto-backup)"); + } + } + + // Migrate (idempotent): open performs it. We capture the prior + // schema version so we can print "applied: N". + if let Cmd::Migrate(_) = &cli.cmd { + let pre_version = peek_schema_version(&db); + let _persister = SqlitePersister::open(config.clone()).map_err(map_open_err_for_cli)?; + let post_version = peek_schema_version(&db); + let applied = post_version + .unwrap_or(0) + .saturating_sub(pre_version.unwrap_or(0)) as usize; + println!("applied: {applied}"); + return Ok(ExitCode::SUCCESS); + } + + match cli.cmd { + Cmd::Migrate(_) | Cmd::Prune(_) | Cmd::Restore(_) => unreachable!(), + Cmd::Backup(args) => { + let persister = SqlitePersister::open(config).map_err(map_open_err_for_cli)?; + run_backup(&persister, args) + } + Cmd::Inspect(args) => { + let persister = SqlitePersister::open(config).map_err(map_open_err_for_cli)?; + run_inspect(&persister, args) + } + Cmd::DeleteWallet(args) => { + let persister = SqlitePersister::open(config).map_err(map_open_err_for_cli)?; + run_delete_wallet(&persister, args) + } + } +} + +fn map_open_err_for_cli(err: WalletStorageError) -> CliError { + match err { + WalletStorageError::AutoBackupDisabled { + operation: AutoBackupOperation::OpenMigration, + } => CliError { + message: "auto-backup directory not configured; pass --no-auto-backup to proceed" + .to_string(), + code: ExitCode::from(1), + }, + WalletStorageError::Io(e) => CliError::runtime(format!("failed to open database: {e}")), + other => CliError::runtime(other.to_string()), + } +} + +fn peek_schema_version(db: &Path) -> Option { + let conn = rusqlite::Connection::open(db).ok()?; + conn.query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get::<_, Option>(0), + ) + .ok() + .flatten() +} + +fn run_backup(persister: &SqlitePersister, args: BackupArgs) -> Result { + if args.out.is_file() { + return Err(CliError::runtime(format!( + "backup destination exists and refuses to overwrite: {}", + args.out.display() + ))); + } + let path = persister + .backup_to(&args.out) + .map_err(|e| CliError::runtime(e.to_string()))?; + println!("{}", path.display()); + Ok(ExitCode::SUCCESS) +} + +fn run_restore( + db: &Path, + args: &RestoreArgs, + auto_backup_dir: Option<&Option>, +) -> Result { + if !args.yes { + return Err(CliError { + message: "refusing to restore without --yes".into(), + code: ExitCode::from(2), + }); + } + let result = if args.no_auto_backup { + eprintln!("warning: auto-backup skipped (--no-auto-backup)"); + SqlitePersister::restore_from_skip_backup(db, &args.from) + } else { + // CLI default mirrors the persister config default + // (`/backups/auto/`). The CLI doesn't open a + // persister here, so we compute the default inline. + let resolved_dir: Option = match auto_backup_dir { + None => Some(default_auto_backup_dir_for_cli(db)), + Some(opt) => opt.clone(), + }; + SqlitePersister::restore_from(db, &args.from, resolved_dir.as_deref()) + }; + match result { + Ok(()) => Ok(ExitCode::SUCCESS), + Err(WalletStorageError::IntegrityCheckFailed { report }) => Err(CliError::validation( + format!("source backup failed integrity check: {report}"), + )), + Err(WalletStorageError::SchemaHistoryMissing) => Err(CliError::validation( + "source backup failed integrity check: schema history missing".to_string(), + )), + Err(WalletStorageError::AutoBackupDisabled { .. }) => Err(CliError::runtime( + "auto-backup directory not configured; pass --no-auto-backup to proceed", + )), + Err(other) => Err(CliError::runtime(other.to_string())), + } +} + +/// Mirror of `platform_wallet_storage::sqlite::config::default_auto_backup_dir` +/// for the CLI's `restore` path (which doesn't go through a +/// persister). +fn default_auto_backup_dir_for_cli(db_path: &Path) -> PathBuf { + let parent = db_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + parent.join("backups").join("auto") +} + +fn run_prune(args: &PruneArgs) -> Result { + if args.keep_last.is_none() && args.max_age.is_none() { + return Err(CliError { + message: "at least one of --keep-last or --max-age is required".into(), + code: ExitCode::from(2), + }); + } + let policy = RetentionPolicy { + keep_last_n: args.keep_last, + max_age: args.max_age, + }; + let report = platform_wallet_storage::sqlite::backup::prune(&args.in_dir, policy) + .map_err(|e| CliError::runtime(e.to_string()))?; + for p in &report.removed { + println!("{}", p.display()); + } + Ok(ExitCode::SUCCESS) +} + +fn run_inspect(persister: &SqlitePersister, args: InspectArgs) -> Result { + let wallet_id = match args.wallet_id.as_deref() { + None => None, + Some(s) => Some(parse_wallet_id(s).map_err(|m| CliError { + message: m, + code: ExitCode::from(2), + })?), + }; + let counts = persister + .inspect_counts(wallet_id.as_ref()) + .map_err(|e| CliError::runtime(e.to_string()))?; + match args.format { + InspectFormat::Text | InspectFormat::Tsv => { + for (table, n) in counts { + println!("{table}\t{n}"); + } + } + InspectFormat::Json => { + let entries: Vec = counts + .into_iter() + .map(|(table, n)| match &wallet_id { + None => serde_json::json!({ "table": table, "count": n }), + Some(id) => serde_json::json!({ + "table": table, + "count": n, + "wallet_id": hex::encode(id), + }), + }) + .collect(); + println!( + "{}", + serde_json::to_string(&entries).map_err(|e| CliError::runtime(e.to_string()))? + ); + } + } + Ok(ExitCode::SUCCESS) +} + +fn run_delete_wallet( + persister: &SqlitePersister, + args: DeleteWalletArgs, +) -> Result { + if !args.yes { + return Err(CliError { + message: "refusing to delete a wallet without --yes".into(), + code: ExitCode::from(2), + }); + } + let wallet_id = parse_wallet_id(&args.wallet_id).map_err(|m| CliError { + message: m, + code: ExitCode::from(2), + })?; + let result = if args.no_auto_backup { + eprintln!("warning: auto-backup skipped (--no-auto-backup)"); + persister.delete_wallet_skip_backup(wallet_id) + } else { + persister.delete_wallet(wallet_id) + }; + match result { + Ok(report) => { + if let Some(path) = &report.backup_path { + println!("{}", path.display()); + } + Ok(ExitCode::SUCCESS) + } + 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())), + } +} diff --git a/packages/rs-platform-wallet-storage/src/lib.rs b/packages/rs-platform-wallet-storage/src/lib.rs new file mode 100644 index 00000000000..c50e546b3cb --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/lib.rs @@ -0,0 +1,56 @@ +//! Storage backends for the `platform-wallet` crate. +//! +//! Today this crate ships the SQLite-backed +//! [`sqlite::SqlitePersister`] implementation of +//! [`PlatformWalletPersistence`](platform_wallet::changeset::PlatformWalletPersistence). +//! The crate is structured so a future `secrets` submodule — a +//! `SecretStore` for mnemonic / private-key material, sketched in +//! [`SECRETS.md`](../SECRETS.md) — can ship alongside it without a +//! crate split. +//! +//! ## Canonical type paths +//! +//! Both work; pick whichever reads better in your call site: +//! +//! ```rust,ignore +//! use platform_wallet_storage::SqlitePersister; // root re-export +//! use platform_wallet_storage::sqlite::SqlitePersister; // submodule re-export +//! use platform_wallet_storage::sqlite::persister::SqlitePersister; // deep path +//! ``` + +#![deny(rust_2018_idioms)] +#![deny(unsafe_code)] + +#[cfg(feature = "sqlite")] +pub mod sqlite; +// pub mod secrets; // reserved — future SecretStore submodule. + +// Convenience re-exports kept under the crate root so embedders don't +// have to spell out the `::sqlite::` middle segment for the common +// names. Adding to or trimming from this list does NOT count as a +// breaking change of the submodule API. +#[cfg(feature = "sqlite")] +#[allow(deprecated)] +pub use sqlite::{ + AutoBackupOperation, DeleteWalletReport, FlushMode, JournalMode, PruneReport, RetentionPolicy, + SqlitePersister, SqlitePersisterConfig, SqlitePersisterError, Synchronous, WalletStorageError, +}; + +// Compile-time assertions — `Send + Sync`, `PlatformWalletPersistence` +// object-safety, and the no-boxed-trait-object error policy. +// Lint-gated to the SQLite feature because they reference its types. +#[cfg(feature = "sqlite")] +#[allow(dead_code)] +const fn _send_sync_check() {} +#[cfg(feature = "sqlite")] +const _: () = { + _send_sync_check::(); + _send_sync_check::(); +}; + +#[cfg(feature = "sqlite")] +#[allow(dead_code)] +fn _object_safety_check(persister: SqlitePersister) { + let _: std::sync::Arc = + std::sync::Arc::new(persister); +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs new file mode 100644 index 00000000000..cacd4c2b32f --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs @@ -0,0 +1,330 @@ +//! Online backup, restore, and retention helpers. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use rusqlite::backup::Backup; +use rusqlite::{Connection, OptionalExtension}; + +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; + +/// Distinguishes auto-backup filenames. +#[derive(Debug, Clone, Copy)] +pub enum BackupKind { + PreMigration { from: i32, to: i32 }, + PreDelete { wallet_id: WalletId }, + PreRestore, +} + +/// Filename for `backup_to(directory)`. +pub fn manual_backup_filename() -> String { + format!("wallet-{}.db", utc_timestamp()) +} + +/// Filename for an auto-backup. +pub fn auto_backup_filename(kind: BackupKind) -> String { + let ts = utc_timestamp(); + match kind { + BackupKind::PreMigration { from, to } => format!("pre-migration-{from}-to-{to}-{ts}.db"), + BackupKind::PreDelete { wallet_id } => { + format!("pre-delete-{}-{ts}.db", hex::encode(wallet_id)) + } + BackupKind::PreRestore => format!("pre-restore-{ts}.db"), + } +} + +/// Take an online backup of `src` to `dest`. Uses the +/// `rusqlite::backup::Backup::run_to_completion` page-stepping API +/// so writers aren't blocked. +pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { + if let Some(parent) = dest.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + std::fs::create_dir_all(parent)?; + } + } + let mut backup_conn = Connection::open(dest)?; + // SEC-011: chmod 600 on Unix so the backup file isn't world/group + // readable just because the process umask was lax. + apply_secure_permissions(dest)?; + let backup = Backup::new(src, &mut backup_conn)?; + // 100 pages × 4 KiB = 400 KiB per step on default SQLite page size. + backup.run_to_completion(100, Duration::from_millis(5), None)?; + 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. +pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), WalletStorageError> { + // 1. Validate source — opens read-only, runs PRAGMA integrity_check, + // requires `refinery_schema_history`, and rejects future schema + // versions. + let src = Connection::open_with_flags( + src_backup, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(|source| WalletStorageError::SourceOpenFailed { source })?; + run_integrity_check(&src, |report| WalletStorageError::IntegrityCheckFailed { + report, + })?; + let has_schema = src + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !has_schema { + return Err(WalletStorageError::SchemaHistoryMissing); + } + let max_supported = crate::sqlite::migrations::embedded_migrations() + .iter() + .map(|(v, _)| *v as i64) + .max() + .unwrap_or(0); + let source_version: Option = src + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .optional()? + .flatten(); + if let Some(v) = source_version { + if v > max_supported { + return Err(WalletStorageError::SchemaVersionUnsupported { + found: v, + max_supported, + }); + } + } + drop(src); + + // 2. Try-lock the destination so we don't replace a DB another + // process holds open. + if dest_db_path.exists() { + use fs2::FileExt; + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(dest_db_path)?; + match f.try_lock_exclusive() { + Ok(()) => { + let _ = FileExt::unlock(&f); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + return Err(WalletStorageError::RestoreDestinationLocked); + } + Err(_) => { + // Advisory locks unsupported on this FS — proceed. + } + } + } + + // 3. Remove any WAL / SHM siblings so SQLite can't open stale + // auxiliary state for the replaced DB. + for ext in ["-wal", "-shm"] { + let sibling = dest_db_path.with_file_name(format!( + "{}{ext}", + dest_db_path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default() + )); + if sibling.exists() { + std::fs::remove_file(&sibling)?; + } + } + + // 4. 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()?; + + // 5. SEC-004: re-run integrity_check on the STAGED file before + // persisting. A torn `std::io::copy` or transient FS error + // that escaped `sync_all`'s notice would otherwise persist a + // corrupted database. If the recheck fails, the temp file + // drops naturally and the live destination stays untouched. + { + let staged = Connection::open_with_flags( + tmp.path(), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(|source| WalletStorageError::SourceOpenFailed { source })?; + run_integrity_check(&staged, |report| WalletStorageError::IntegrityCheckFailed { + report, + })?; + } + + // 6. Persist atomically over the destination. + tmp.persist(dest_db_path) + .map_err(|e| WalletStorageError::Io(e.error))?; + + // 7. SEC-004: chmod 600 on Unix so the restored DB doesn't inherit + // a wider mode from a previous file at the same path. Windows + // has no equivalent permission model here — skipped. + apply_secure_permissions(dest_db_path)?; + Ok(()) +} + +/// Run `PRAGMA integrity_check` and return `Ok(())` if SQLite returns +/// "ok". Any other returned text becomes a typed `IntegrityCheckFailed` +/// via the caller-supplied builder; an underlying rusqlite error +/// surfaces as `IntegrityCheckRunFailed`. +fn run_integrity_check(conn: &Connection, on_failure: F) -> Result<(), WalletStorageError> +where + F: FnOnce(String) -> WalletStorageError, +{ + let report: String = conn + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .map_err(|source| WalletStorageError::IntegrityCheckRunFailed { source })?; + if report == "ok" { + Ok(()) + } else { + Err(on_failure(report)) + } +} + +/// Apply retention to a directory. Files that match the recognised +/// backup-name prefixes are eligible; others are ignored. +/// +// INTENTIONAL(CODE-007): prune fails-fast on the first I/O error +// rather than collecting per-file failures into PruneReport. +// Acceptable because the operator gets a typed error with the +// offending path; retrying prune is idempotent. +pub fn prune(dir: &Path, policy: RetentionPolicy) -> Result { + let entries = std::fs::read_dir(dir)?; + let mut files: Vec<(SystemTime, PathBuf)> = Vec::new(); + for entry in entries { + let entry = entry?; + let path = entry.path(); + if !is_backup_file(&path) { + continue; + } + let ts = backup_timestamp(&path).unwrap_or_else(|| { + entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) + }); + files.push((ts, path)); + } + // Newest first. + files.sort_by(|a, b| b.0.cmp(&a.0)); + let now = SystemTime::now(); + let mut removed = Vec::new(); + let mut kept = 0; + for (idx, (ts, path)) in files.into_iter().enumerate() { + let pass_count = match policy.keep_last_n { + Some(n) => idx < n, + None => true, + }; + let pass_age = match policy.max_age { + Some(max) => now.duration_since(ts).map(|d| d <= max).unwrap_or(true), + None => true, + }; + if pass_count && pass_age { + kept += 1; + } else { + std::fs::remove_file(&path)?; + removed.push(path); + } + } + // Sort `removed` oldest-first for deterministic output. + removed.sort(); + Ok(PruneReport { removed, kept }) +} + +fn is_backup_file(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + return false; + }; + (name.starts_with("wallet-") + || name.starts_with("pre-migration-") + || name.starts_with("pre-delete-") + || name.starts_with("pre-restore-")) + && name.ends_with(".db") +} + +fn backup_timestamp(path: &Path) -> Option { + let name = path.file_name()?.to_str()?; + // Find the last `YYYYMMDDTHHMMSSZ` token before `.db`. + let stem = name.strip_suffix(".db")?; + let token = stem.rsplit('-').next()?; + parse_compact_timestamp(token) +} + +fn parse_compact_timestamp(s: &str) -> Option { + // Expect 16 chars: `YYYYMMDDTHHMMSSZ`. + if s.len() != 16 { + return None; + } + let year: i32 = s.get(0..4)?.parse().ok()?; + let month: u32 = s.get(4..6)?.parse().ok()?; + let day: u32 = s.get(6..8)?.parse().ok()?; + if s.as_bytes().get(8) != Some(&b'T') { + return None; + } + let hour: u32 = s.get(9..11)?.parse().ok()?; + let minute: u32 = s.get(11..13)?.parse().ok()?; + let second: u32 = s.get(13..15)?.parse().ok()?; + if s.as_bytes().get(15) != Some(&b'Z') { + return None; + } + use chrono::{TimeZone, Utc}; + let dt = Utc + .with_ymd_and_hms(year, month, day, hour, minute, second) + .single()?; + Some(SystemTime::UNIX_EPOCH + Duration::from_secs(dt.timestamp().max(0) as u64)) +} + +fn utc_timestamp() -> String { + chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manual_backup_filename_matches_regex() { + let n = manual_backup_filename(); + assert!(n.starts_with("wallet-")); + assert!(n.ends_with(".db")); + assert_eq!(n.len(), "wallet-YYYYMMDDTHHMMSSZ.db".len()); + } + + #[test] + fn timestamp_roundtrip() { + let ts = parse_compact_timestamp("20260101T000000Z").unwrap(); + let secs = ts.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); + // 2026-01-01 00:00:00 UTC = 1767225600 + assert_eq!(secs, 1767225600); + } + + #[test] + fn is_backup_file_recognises_prefixes() { + assert!(is_backup_file(Path::new("/tmp/wallet-20260101T000000Z.db"))); + assert!(is_backup_file(Path::new( + "/tmp/pre-migration-1-to-2-20260101T000000Z.db" + ))); + assert!(is_backup_file(Path::new( + "/tmp/pre-delete-abcd-20260101T000000Z.db" + ))); + assert!(is_backup_file(Path::new( + "/tmp/pre-restore-20260101T000000Z.db" + ))); + assert!(!is_backup_file(Path::new("/tmp/notes.txt"))); + assert!(!is_backup_file(Path::new("/tmp/wallet.db"))); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/buffer.rs b/packages/rs-platform-wallet-storage/src/sqlite/buffer.rs new file mode 100644 index 00000000000..7519225a9d1 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/buffer.rs @@ -0,0 +1,68 @@ +//! Per-wallet in-memory buffer. +//! +//! `store` merges the incoming changeset into a per-wallet accumulator +//! using each sub-changeset's `Merge` impl. `flush` drains one wallet's +//! accumulator and returns the owned changeset for the schema dispatcher +//! to write under one SQLite transaction. The buffer never owns the +//! database connection. + +use std::collections::HashMap; +use std::sync::Mutex; + +use platform_wallet::changeset::{Merge, PlatformWalletChangeSet}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; + +#[derive(Default)] +pub struct Buffer { + inner: Mutex>, +} + +impl Buffer { + pub fn new() -> Self { + Self::default() + } + + /// Merge a changeset into the buffer for `wallet_id`. + pub fn store( + &self, + wallet_id: WalletId, + cs: PlatformWalletChangeSet, + ) -> Result<(), WalletStorageError> { + if cs.is_empty() { + return Ok(()); + } + let mut guard = self + .inner + .lock() + .map_err(|_| WalletStorageError::LockPoisoned)?; + guard.entry(wallet_id).or_default().merge(cs); + Ok(()) + } + + /// Drain (return) the buffered changeset for `wallet_id`. Returns + /// `None` if there is no pending data. + pub fn drain( + &self, + wallet_id: &WalletId, + ) -> Result, WalletStorageError> { + let mut guard = self + .inner + .lock() + .map_err(|_| WalletStorageError::LockPoisoned)?; + Ok(guard.remove(wallet_id).filter(|cs| !cs.is_empty())) + } + + /// Every wallet currently holding buffered data, sorted by id for + /// deterministic flush ordering. + pub fn dirty_wallets(&self) -> Result, WalletStorageError> { + let guard = self + .inner + .lock() + .map_err(|_| WalletStorageError::LockPoisoned)?; + let mut ids: Vec = guard.keys().copied().collect(); + ids.sort(); + Ok(ids) + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/config.rs b/packages/rs-platform-wallet-storage/src/sqlite/config.rs new file mode 100644 index 00000000000..ce69361120a --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/config.rs @@ -0,0 +1,136 @@ +//! Configuration for [`SqlitePersister`](crate::SqlitePersister). + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// When `store()` makes data durable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FlushMode { + /// `store()` only buffers. Caller must call `flush()` (or + /// `commit_writes()`) to make changes durable. + Manual, + /// `store()` flushes inline at the end of the call. Safest default. + #[default] + Immediate, +} + +/// SQLite journal mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum JournalMode { + #[default] + Wal, + Delete, + Memory, + Off, + Truncate, + Persist, +} + +impl JournalMode { + pub(crate) fn pragma_value(self) -> &'static str { + match self { + JournalMode::Wal => "WAL", + JournalMode::Delete => "DELETE", + JournalMode::Memory => "MEMORY", + JournalMode::Off => "OFF", + JournalMode::Truncate => "TRUNCATE", + JournalMode::Persist => "PERSIST", + } + } +} + +/// SQLite synchronous mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Synchronous { + Off, + #[default] + Normal, + Full, + Extra, +} + +impl Synchronous { + pub(crate) fn pragma_value(self) -> &'static str { + match self { + Synchronous::Off => "OFF", + Synchronous::Normal => "NORMAL", + Synchronous::Full => "FULL", + Synchronous::Extra => "EXTRA", + } + } +} + +/// Persister configuration. +/// +/// Defaults match the dash-evo-tool behaviour: `Immediate` flushes, +/// 5 s busy timeout, WAL journal, `NORMAL` synchronous, automatic +/// backups under `/backups/auto/`. +#[derive(Debug, Clone)] +pub struct SqlitePersisterConfig { + pub path: PathBuf, + pub flush_mode: FlushMode, + pub busy_timeout: Duration, + pub journal_mode: JournalMode, + pub synchronous: Synchronous, + /// Where automatic backups (pre-migration, pre-wallet-deletion) are + /// written. Set to `None` to disable automatic backups — library + /// API destructive operations then return + /// [`WalletStorageError::AutoBackupDisabled`](crate::WalletStorageError::AutoBackupDisabled). + pub auto_backup_dir: Option, +} + +impl SqlitePersisterConfig { + /// Build a config with sensible defaults for the given DB path. + pub fn new(path: impl Into) -> Self { + let path = path.into(); + let auto_backup_dir = default_auto_backup_dir(&path); + Self { + path, + flush_mode: FlushMode::default(), + busy_timeout: Duration::from_secs(5), + journal_mode: JournalMode::default(), + synchronous: Synchronous::default(), + auto_backup_dir: Some(auto_backup_dir), + } + } + + /// Override flush mode. + pub fn with_flush_mode(mut self, mode: FlushMode) -> Self { + self.flush_mode = mode; + self + } + + /// Override auto-backup dir. Pass `None` to opt out. + pub fn with_auto_backup_dir(mut self, dir: Option) -> Self { + self.auto_backup_dir = dir; + self + } +} + +/// `/backups/auto/` (or `./backups/auto/` if the DB path has no parent). +pub(crate) fn default_auto_backup_dir(db_path: &Path) -> PathBuf { + let parent = db_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + parent.join("backups").join("auto") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_spec() { + let cfg = SqlitePersisterConfig::new("/tmp/w.db"); + assert_eq!(cfg.flush_mode, FlushMode::Immediate); + assert_eq!(cfg.busy_timeout, Duration::from_secs(5)); + assert_eq!(cfg.journal_mode, JournalMode::Wal); + assert_eq!(cfg.synchronous, Synchronous::Normal); + assert_eq!( + cfg.auto_backup_dir.as_deref(), + Some(std::path::Path::new("/tmp/backups/auto")) + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs new file mode 100644 index 00000000000..8957ba7a82f --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -0,0 +1,274 @@ +//! Typed errors for `platform-wallet-storage`. +//! +//! Every variant carries the upstream error via `#[source]` (or +//! `#[from]` where the conversion is the only thing the trait does), +//! never via a stringified copy. Variants never store user-facing +//! prose — the `#[error("...")]` attribute provides the renderable +//! `Display` form; the typed fields carry diagnostics. +//! +//! At the `PlatformWalletPersistence` trait boundary, this type +//! converts into `PersistenceError`: `LockPoisoned` keeps its +//! dedicated variant, everything else flows through +//! `PersistenceError::Backend` with the full `Display` chain. + +use std::path::PathBuf; + +use platform_wallet::changeset::PersistenceError; + +use crate::sqlite::util::safe_cast::SafeCastTarget; + +/// Which automatic-backup operation was attempted when the +/// configured backup directory was missing or otherwise unwritable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum AutoBackupOperation { + #[error("open (pending migration)")] + OpenMigration, + #[error("delete_wallet")] + DeleteWallet, + #[error("restore_from")] + Restore, +} + +/// Errors produced by the wallet-storage SQLite backend. +/// +/// `SqlitePersisterError` is preserved as a deprecated alias for one +/// cycle; new code should use `WalletStorageError`. +#[derive(Debug, thiserror::Error)] +pub enum WalletStorageError { + /// File-system I/O error reaching the database or backup files. + #[error("io error")] + Io(#[from] std::io::Error), + + /// Error from rusqlite — covers SQL errors, busy timeouts, and + /// schema-level failures alike. The inner `rusqlite::Error` + /// already discriminates between them. + #[error("sqlite error")] + Sqlite(#[from] rusqlite::Error), + + /// Refinery migration runner failure. + #[error("migration error")] + Migration(#[from] refinery::Error), + + /// The migration runner left the schema in an inconsistent state + /// (some migrations applied, some still pending). + #[error( + "migration left the database in a dirty state \ + (applied={applied} pending={pending})" + )] + MigrationDirty { applied: usize, pending: usize }, + + /// `PRAGMA integrity_check` ran successfully but reported a + /// non-`ok` result. `report` carries SQLite's own diagnostic + /// text — not a user-facing message, not a stringified source. + #[error("integrity check failed: {report}")] + IntegrityCheckFailed { report: String }, + + /// Failed to even run the integrity-check pragma. + #[error("integrity check could not run")] + IntegrityCheckRunFailed { + #[source] + source: rusqlite::Error, + }, + + /// Cannot open the candidate source database file (most likely + /// not a SQLite database at all, or bytes are torn). + #[error("cannot open candidate source database")] + SourceOpenFailed { + #[source] + source: rusqlite::Error, + }, + + /// Source backup file lacks the `refinery_schema_history` table — + /// it isn't a wallet-storage database. + #[error("source backup is missing schema_history (not a platform-wallet-storage database)")] + SchemaHistoryMissing, + + /// Source backup carries a schema version beyond what this build + /// can apply. + #[error( + "source backup schema version {found} is beyond the supported maximum {max_supported}" + )] + SchemaVersionUnsupported { found: i64, max_supported: i64 }, + + /// A destructive operation needed an automatic backup but the + /// configuration disabled them. + #[error("auto-backup is disabled for operation: {operation}")] + AutoBackupDisabled { operation: AutoBackupOperation }, + + /// The configured auto-backup directory could not be created or + /// written to. + #[error("auto-backup directory {} could not be prepared", dir.display())] + AutoBackupDirUnwritable { + dir: PathBuf, + #[source] + source: std::io::Error, + }, + + /// `delete_wallet` (or another wallet-id-keyed operation) was + /// called with an id that has no matching `wallet_metadata` row. + #[error("wallet not found: {}", hex::encode(wallet_id))] + WalletNotFound { wallet_id: [u8; 32] }, + + /// A previous holder of an internal mutex panicked. Maps to the + /// trait-level [`PersistenceError::LockPoisoned`] so callers can + /// still pattern-match the boundary variant cleanly. + #[error("persister lock poisoned")] + LockPoisoned, + + /// `restore_from` tried to acquire an exclusive file-lock on the + /// destination and couldn't — another process is holding it open. + #[error("restore destination is locked or in use")] + RestoreDestinationLocked, + + /// A wallet-id hex string couldn't be parsed. + #[error("invalid wallet id: bad hex")] + InvalidWalletIdHex { + #[source] + source: hex::FromHexError, + }, + + /// A wallet-id hex string had the wrong length (must be 64 chars + /// for a 32-byte id). + #[error("invalid wallet id length: expected 64 hex chars, got {actual}")] + InvalidWalletIdLength { actual: usize }, + + /// A `SqlitePersisterConfig` field carries an unsupported value + /// (e.g. `synchronous = Off`). The `reason` is a compile-time + /// `&'static str` constant naming the rejected setting. + #[error("invalid configuration: {reason}")] + ConfigInvalid { reason: &'static str }, + + /// bincode-serde refused to encode a value (typically because + /// the value's serde representation needs `deserialize_any`-style + /// dispatch — see dpp's `IdentityPublicKey` workaround). + #[error("bincode encode error")] + BincodeEncode { + #[source] + source: bincode::error::EncodeError, + }, + + /// bincode-serde refused to decode a payload. + #[error("bincode decode error")] + BincodeDecode { + #[source] + source: bincode::error::DecodeError, + }, + + /// A typed-column decode failed (e.g. outpoint had the wrong + /// length, or a column held a value the schema doesn't recognise). + #[error("blob/column decode failed: {reason}")] + BlobDecode { reason: &'static str }, + + /// A typed-column decode failed because an underlying + /// `dashcore::hashes` deserialisation rejected the bytes. + #[error("hash decode failed")] + HashDecode { + #[source] + source: dashcore::hashes::Error, + }, + + /// A `dashcore` consensus encode/decode failed. + #[error("dashcore consensus encoding failed")] + ConsensusCodec { + #[source] + source: dashcore::consensus::encode::Error, + }, + + /// The CLI's `backup` subcommand refuses to overwrite an existing + /// destination file. + #[error("backup destination already exists: {}", path.display())] + BackupDestinationExists { path: PathBuf }, + + /// A value couldn't be cast to the database's native i64 + /// representation without losing magnitude. + #[error("integer overflow casting `{field}` (value={value}) to {target}")] + IntegerOverflow { + field: &'static str, + value: u64, + target: SafeCastTarget, + }, + + /// A `load()` call succeeded but skipped some sub-areas because + /// their reconstruction is not yet implemented. The `unimplemented` + /// list names the affected `ClientStartState` field paths so + /// callers can decide whether to proceed. + /// + /// `load()` itself returns `Ok(ClientStartState)` and surfaces + /// the same information via `tracing::warn!`; this variant exists + /// for callers that route through trait-error propagation paths + /// or explicitly want partial-completion as a value. + #[error( + "load() did not reconstruct {} sub-area(s); unimplemented: {unimplemented:?}", + unimplemented.len() + )] + LoadIncomplete { + unimplemented: &'static [&'static str], + }, +} + +/// Deprecated alias preserved for one cycle. Switch downstream +/// references to [`WalletStorageError`]. +#[deprecated(since = "3.1.0-dev.1", note = "renamed to WalletStorageError")] +pub type SqlitePersisterError = WalletStorageError; + +impl From for PersistenceError { + fn from(err: WalletStorageError) -> Self { + match err { + WalletStorageError::LockPoisoned => PersistenceError::LockPoisoned, + other => PersistenceError::Backend(format!("{}", DisplayChain(&other))), + } + } +} + +/// Renders an error and its `#[source]` chain for the +/// `PersistenceError::Backend` (`String`) boundary. The trait can't +/// carry typed sources, so the chain is concatenated for diagnostic +/// purposes — every typed variant is still preserved on the +/// `WalletStorageError` value the trait `From` impl consumes. +struct DisplayChain<'a>(&'a WalletStorageError); + +impl std::fmt::Display for DisplayChain<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::error::Error; + write!(f, "{}", self.0)?; + let mut cur: Option<&dyn Error> = self.0.source(); + while let Some(err) = cur { + write!(f, ": {err}")?; + cur = err.source(); + } + Ok(()) + } +} + +impl WalletStorageError { + /// Construct a typed `BlobDecode` error from a static reason. + /// Used by schema modules that hit a structural decode error + /// (e.g. an outpoint column that isn't 36 bytes). + pub(crate) fn blob_decode(reason: &'static str) -> Self { + Self::BlobDecode { reason } + } +} + +impl From for WalletStorageError { + fn from(source: bincode::error::EncodeError) -> Self { + Self::BincodeEncode { source } + } +} + +impl From for WalletStorageError { + fn from(source: bincode::error::DecodeError) -> Self { + Self::BincodeDecode { source } + } +} + +impl From for WalletStorageError { + fn from(source: dashcore::hashes::Error) -> Self { + Self::HashDecode { source } + } +} + +impl From for WalletStorageError { + fn from(source: dashcore::consensus::encode::Error) -> Self { + Self::ConsensusCodec { source } + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs new file mode 100644 index 00000000000..690bc894a74 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -0,0 +1,42 @@ +//! Schema migration plumbing. +//! +//! Embeds every Rust migration under `migrations/` at compile time +//! (see `refinery::embed_migrations!`). The `run` function applies any +//! pending migrations to the supplied connection. + +// `embed_migrations!` generates a `migrations` module with a `runner()` +// function. The path is relative to the crate root (where `Cargo.toml` +// lives). +refinery::embed_migrations!("./migrations"); + +/// Apply every pending migration to `conn`. +pub fn run(conn: &mut rusqlite::Connection) -> Result { + migrations::runner().run(conn) +} + +/// List `(version, name)` of every embedded migration. Used by tests and +/// the migration-drift hash check (TC-029). +pub fn embedded_migrations() -> Vec<(i32, String)> { + migrations::runner() + .get_migrations() + .iter() + .map(|m| (m.version(), m.name().to_string())) + .collect() +} + +/// SHA-256 over `(version, name)` of every embedded migration in version +/// order. Pinning this in tests catches edits to committed migrations +/// (forbidden by NFR-8 append-only policy). +pub fn embedded_migrations_fingerprint() -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut entries = embedded_migrations(); + entries.sort_by_key(|(v, _)| *v); + let mut hasher = Sha256::new(); + for (v, name) in entries { + hasher.update(v.to_be_bytes()); + hasher.update([0u8]); + hasher.update(name.as_bytes()); + hasher.update([0u8]); + } + hasher.finalize().into() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs new file mode 100644 index 00000000000..936de2e57d7 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs @@ -0,0 +1,21 @@ +//! SQLite-backed persistence for `platform-wallet`. +//! +//! Implements [`PlatformWalletPersistence`](platform_wallet::changeset::PlatformWalletPersistence) +//! with a per-wallet in-memory buffer, atomic per-wallet flushes, online +//! backup, retention, and a maintenance CLI. The submodules form the +//! internal layout — most callers reach for the re-exports at the crate +//! root instead. + +pub mod backup; +pub mod buffer; +pub mod config; +pub mod error; +pub mod migrations; +pub mod persister; +pub mod schema; +pub mod util; + +pub use config::{FlushMode, JournalMode, SqlitePersisterConfig, Synchronous}; +#[allow(deprecated)] +pub use error::{AutoBackupOperation, SqlitePersisterError, WalletStorageError}; +pub use persister::{DeleteWalletReport, PruneReport, RetentionPolicy, SqlitePersister}; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs new file mode 100644 index 00000000000..a80bd7cc522 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -0,0 +1,650 @@ +//! [`SqlitePersister`] — the canonical `PlatformWalletPersistence` impl. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use rusqlite::{Connection, OptionalExtension}; + +use platform_wallet::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::backup::{self, BackupKind}; +use crate::sqlite::buffer::Buffer; +use crate::sqlite::config::{FlushMode, SqlitePersisterConfig, Synchronous}; +use crate::sqlite::error::{AutoBackupOperation, WalletStorageError}; +use crate::sqlite::schema::{self, PER_WALLET_TABLES}; +use crate::sqlite::util::permissions::apply_secure_permissions; +use crate::sqlite::util::safe_cast; + +/// Sub-areas of `ClientStartState` that `load()` does not yet +/// reconstruct (blocked on upstream `Wallet::from_persisted`). +/// Surfaced via the [`WalletStorageError::LoadIncomplete`] variant +/// and a `tracing::warn!` whenever `load` returns. +pub(crate) const LOAD_UNIMPLEMENTED: &[&str] = &["ClientStartState::wallets"]; + +/// Outcome of a `prune_backups` call. +#[derive(Debug, Clone)] +pub struct PruneReport { + /// Paths that were unlinked, sorted oldest-first by filename + /// timestamp. + pub removed: Vec, + /// Number of files that remain in the directory after pruning. + pub kept: usize, +} + +/// Outcome of a `delete_wallet` / `delete_wallet_skip_backup` call. +#[derive(Debug, Clone)] +pub struct DeleteWalletReport { + pub wallet_id: WalletId, + /// Absolute path of the pre-delete auto-backup written before the + /// cascade. `None` ONLY when the caller went through + /// [`SqlitePersister::delete_wallet_skip_backup`] — every + /// `delete_wallet` success returns `Some(path)`. + pub backup_path: Option, + pub rows_removed_per_table: BTreeMap<&'static str, usize>, +} + +/// Retention policy for `prune_backups`. +/// +/// **AND-semantics**: a file is kept iff it satisfies BOTH rules. A +/// policy with `keep_last_n = Some(3)` and `max_age = Some(30d)` keeps +/// at most the three newest backups AND only those younger than 30 +/// days — a four-day-old backup that's the fifth-newest is removed. +/// `RetentionPolicy::default()` (both `None`) keeps every file. +#[derive(Debug, Clone, Copy, Default)] +pub struct RetentionPolicy { + pub keep_last_n: Option, + pub max_age: Option, +} + +impl RetentionPolicy { + pub fn keep_last(n: usize) -> Self { + Self { + keep_last_n: Some(n), + max_age: None, + } + } + pub fn older_than(d: std::time::Duration) -> Self { + Self { + keep_last_n: None, + max_age: Some(d), + } + } +} + +/// SQLite-backed `PlatformWalletPersistence`. +pub struct SqlitePersister { + config: SqlitePersisterConfig, + // INTENTIONAL(CODE-001): single connection serializes reads through + // the write lock. Acceptable for current workload (per-wallet + // operations, small read footprint); revisit if read contention + // becomes measurable. Splitting into a read-only `r2d2` pool over + // the same WAL-mode file is the planned follow-up. + conn: Arc>, + buffer: Buffer, +} + +impl SqlitePersister { + /// Open or create the SQLite DB at `config.path`. Applies pragmas, + /// runs migrations, optionally takes a pre-migration auto-backup. + pub fn open(config: SqlitePersisterConfig) -> Result { + validate_config(&config)?; + if let Some(parent) = config.path.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + // Parent dir must exist — refuse silently creating it + // to keep "bad path" errors typed (NFR-6). + return Err(WalletStorageError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("database parent directory not found: {}", parent.display()), + ))); + } + } + + // Open the connection AND apply pragmas before checking for + // pending migrations so the integrity probe sees the configured + // journal mode and busy timeout. + let mut conn = Connection::open(&config.path)?; + // SEC-011: chmod 600 on Unix so a freshly created DB doesn't + // inherit a wider mode from the process umask. Idempotent on + // re-open. + apply_secure_permissions(&config.path)?; + apply_pragmas(&mut conn, &config)?; + + // Determine whether `schema_history` exists *before* we run + // migrations — that's the signal for "is this DB pre-existing + // or brand-new?" (FR-15 vs FR-16). `.optional()?` distinguishes + // a genuine "no row" answer from a real SQL error, which we + // propagate. + let had_schema_history = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + let pending = crate::sqlite::migrations::embedded_migrations(); + let pending_count = if had_schema_history { + count_pending(&mut conn, &pending)? + } else { + pending.len() + }; + + if pending_count > 0 && had_schema_history { + let from = current_schema_version(&conn)?.unwrap_or(0); + let to = pending.iter().map(|(v, _)| *v).max().unwrap_or(from); + run_auto_backup( + &conn, + config.auto_backup_dir.as_deref(), + BackupKind::PreMigration { from, to }, + AutoBackupOperation::OpenMigration, + )?; + } + + // Apply migrations. + let _report = crate::sqlite::migrations::run(&mut conn)?; + + Ok(Self { + config, + conn: Arc::new(Mutex::new(conn)), + buffer: Buffer::new(), + }) + } + + /// Take a manual online backup. `dest` may be a directory (auto- + /// named `wallet-.db`) or a full file path (must not pre-exist). + pub fn backup_to(&self, dest: &Path) -> Result { + let resolved = if dest.is_dir() { + dest.join(backup::manual_backup_filename()) + } else { + if dest.exists() { + return Err(WalletStorageError::BackupDestinationExists { + path: dest.to_path_buf(), + }); + } + dest.to_path_buf() + }; + let conn = self.conn()?; + backup::run_to(&conn, &resolved)?; + Ok(resolved.canonicalize().unwrap_or(resolved)) + } + + /// Restore a backup over `dest_db_path`. Destination must not be + /// open in this process. Associated function — no `&self`. + /// + /// Takes a pre-restore auto-backup of the live destination + /// database (when `auto_backup_dir` is `Some`) before persisting + /// the staged source. Refuses with + /// [`WalletStorageError::AutoBackupDisabled`] when the directory + /// is `None`; pass `auto_backup_dir = None` only via the CLI's + /// `--no-auto-backup` flag (or directly through + /// [`restore_from_skip_backup`](Self::restore_from_skip_backup)). + pub fn restore_from( + dest_db_path: &Path, + src_backup: &Path, + auto_backup_dir: Option<&Path>, + ) -> Result<(), WalletStorageError> { + Self::restore_from_inner(dest_db_path, src_backup, auto_backup_dir, false) + } + + /// Restore a backup over `dest_db_path` WITHOUT taking a + /// pre-restore auto-backup. + /// + /// Library consumers should prefer [`restore_from`](Self::restore_from) + /// — it's safe by default. This entry point exists so the CLI's + /// `--no-auto-backup` flag can deliver on its name regardless of + /// `auto_backup_dir`. + pub fn restore_from_skip_backup( + dest_db_path: &Path, + src_backup: &Path, + ) -> Result<(), WalletStorageError> { + Self::restore_from_inner(dest_db_path, src_backup, None, true) + } + + fn restore_from_inner( + dest_db_path: &Path, + src_backup: &Path, + auto_backup_dir: Option<&Path>, + skip_backup: bool, + ) -> Result<(), WalletStorageError> { + if !skip_backup && dest_db_path.exists() { + let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled { + operation: AutoBackupOperation::Restore, + })?; + // Open the destination read-only just long enough to + // page-stream a snapshot to disk under auto_backup_dir. + let dest_conn = Connection::open_with_flags( + dest_db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + )?; + run_auto_backup( + &dest_conn, + Some(dir), + BackupKind::PreRestore, + AutoBackupOperation::Restore, + )?; + drop(dest_conn); + } + backup::restore_from(dest_db_path, src_backup) + } + + /// Apply retention to a directory of `wallet-*.db` (and/or + /// `pre-*-*.db`) files. + pub fn prune_backups( + &self, + dir: &Path, + policy: RetentionPolicy, + ) -> Result { + backup::prune(dir, policy) + } + + /// Cascade-delete every row owned by `wallet_id`. Takes a + /// pre-delete auto-backup before the cascade and refuses if + /// `auto_backup_dir` is `None` (FR-18). For the library-API, + /// safe-by-default route. + /// + /// To skip the auto-backup explicitly — wired up by the CLI's + /// `--no-auto-backup` — call + /// [`delete_wallet_skip_backup`](Self::delete_wallet_skip_backup). + pub fn delete_wallet( + &self, + wallet_id: WalletId, + ) -> Result { + self.delete_wallet_inner(wallet_id, false) + } + + /// Cascade-delete every row owned by `wallet_id` WITHOUT taking + /// an auto-backup. + /// + /// Library consumers should prefer [`delete_wallet`](Self::delete_wallet) + /// — it's safe by default. This entry point exists so the CLI's + /// `--no-auto-backup` flag can deliver on its name regardless of + /// `auto_backup_dir`. Returns `DeleteWalletReport.backup_path = + /// None` to signal the backup was intentionally skipped. + pub fn delete_wallet_skip_backup( + &self, + wallet_id: WalletId, + ) -> Result { + self.delete_wallet_inner(wallet_id, true) + } + + fn delete_wallet_inner( + &self, + wallet_id: WalletId, + skip_backup: bool, + ) -> Result { + // Existence check FIRST — refusing on an unknown wallet must + // not waste a backup file. `.optional()?` propagates real SQL + // errors (busy / corrupt) instead of swallowing them. + { + let conn = self.conn()?; + let exists = conn + .query_row( + "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !exists { + return Err(WalletStorageError::WalletNotFound { wallet_id }); + } + } + let backup_path = if skip_backup { + None + } else { + let conn = self.conn()?; + run_auto_backup( + &conn, + self.config.auto_backup_dir.as_deref(), + BackupKind::PreDelete { wallet_id }, + AutoBackupOperation::DeleteWallet, + )? + }; + let mut conn = self.conn()?; + let tx = conn.transaction()?; + let mut rows_removed_per_table = BTreeMap::new(); + for &table in PER_WALLET_TABLES { + // SQL injection note: `table` comes from a `&'static + // &'static str` constant compiled into the binary. There + // is no user input on this path. + let n: i64 = tx + .query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE wallet_id = ?1"), + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .optional()? + .unwrap_or(0); + rows_removed_per_table.insert(table, usize::try_from(n).unwrap_or(usize::MAX)); + } + crate::sqlite::schema::wallet_meta::delete(&tx, &wallet_id)?; + tx.commit()?; + Ok(DeleteWalletReport { + wallet_id, + backup_path, + rows_removed_per_table, + }) + } + + /// In Manual mode: flush every dirty wallet. In Immediate mode: no-op. + pub fn commit_writes(&self) -> Result<(), PersistenceError> { + match self.config.flush_mode { + FlushMode::Immediate => Ok(()), + FlushMode::Manual => { + let dirty = self + .buffer + .dirty_wallets() + .map_err(PersistenceError::from)?; + for id in dirty { + self.flush_inner(&id)?; + } + Ok(()) + } + } + } + + /// `inspect` row-count summary. With `wallet_id = Some(id)`, scoped + /// to that wallet; otherwise total counts across all wallets. + pub fn inspect_counts( + &self, + wallet_id: Option<&WalletId>, + ) -> Result, WalletStorageError> { + let conn = self.conn()?; + let mut out = Vec::with_capacity(PER_WALLET_TABLES.len()); + for &table in PER_WALLET_TABLES { + // `table` is a compile-time constant — no SQL injection + // surface despite the `format!`. + let n: i64 = match wallet_id { + Some(id) => conn + .query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE wallet_id = ?1"), + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .optional()? + .unwrap_or(0), + None => conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .optional()? + .unwrap_or(0), + }; + out.push((table, usize::try_from(n).unwrap_or(usize::MAX))); + } + Ok(out) + } + + /// Lock the write connection. + pub(crate) fn conn(&self) -> Result, WalletStorageError> { + self.conn + .lock() + .map_err(|_| WalletStorageError::LockPoisoned) + } + + // INTENTIONAL(PROJ-005): downstream cannot meaningfully enable + // test-helpers because the methods are + // `#[cfg(any(test, feature = "test-helpers"))]`; the feature + // exists only so this crate's own integration tests can pull + // themselves in via dev-deps with the feature on. Naming + // convention warning (Cargo convention is `__test-helpers`) is + // acknowledged and not adopted — see Cargo.toml. + /// 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 cannot reach it. + #[doc(hidden)] + #[cfg(any(test, feature = "test-helpers"))] + pub fn lock_conn_for_test(&self) -> MutexGuard<'_, Connection> { + self.conn.lock().expect("conn mutex poisoned") + } + + /// Test-only: read the resolved config. Same visibility rules as + /// [`lock_conn_for_test`](Self::lock_conn_for_test). + #[doc(hidden)] + #[cfg(any(test, feature = "test-helpers"))] + pub fn config_for_test(&self) -> &SqlitePersisterConfig { + &self.config + } + + fn flush_inner(&self, wallet_id: &WalletId) -> Result<(), PersistenceError> { + let cs = self + .buffer + .drain(wallet_id) + .map_err(PersistenceError::from)?; + let Some(cs) = cs else { return Ok(()) }; + let mut conn = self.conn().map_err(PersistenceError::from)?; + let tx = conn + .transaction() + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + if let Some(meta) = cs.wallet_metadata.as_ref() { + schema::wallet_meta::upsert(&tx, wallet_id, meta).map_err(PersistenceError::from)?; + } + if !cs.account_registrations.is_empty() { + schema::accounts::apply_registrations(&tx, wallet_id, &cs.account_registrations) + .map_err(PersistenceError::from)?; + } + if !cs.account_address_pools.is_empty() { + schema::accounts::apply_pools(&tx, wallet_id, &cs.account_address_pools) + .map_err(PersistenceError::from)?; + } + if let Some(core) = cs.core.as_ref() { + schema::core_state::apply(&tx, wallet_id, core).map_err(PersistenceError::from)?; + } + if let Some(identities) = cs.identities.as_ref() { + schema::identities::apply(&tx, wallet_id, identities) + .map_err(PersistenceError::from)?; + } + if let Some(keys) = cs.identity_keys.as_ref() { + schema::identity_keys::apply(&tx, wallet_id, keys).map_err(PersistenceError::from)?; + } + if let Some(contacts) = cs.contacts.as_ref() { + schema::contacts::apply(&tx, wallet_id, contacts).map_err(PersistenceError::from)?; + } + if let Some(addrs) = cs.platform_addresses.as_ref() { + schema::platform_addrs::apply(&tx, wallet_id, addrs).map_err(PersistenceError::from)?; + } + if let Some(locks) = cs.asset_locks.as_ref() { + schema::asset_locks::apply(&tx, wallet_id, locks).map_err(PersistenceError::from)?; + } + if let Some(balances) = cs.token_balances.as_ref() { + schema::token_balances::apply(&tx, wallet_id, balances) + .map_err(PersistenceError::from)?; + } + if cs.dashpay_profiles.is_some() || cs.dashpay_payments_overlay.is_some() { + schema::dashpay::apply( + &tx, + wallet_id, + cs.dashpay_profiles.as_ref(), + cs.dashpay_payments_overlay.as_ref(), + ) + .map_err(PersistenceError::from)?; + } + tx.commit() + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + Ok(()) + } +} + +impl PlatformWalletPersistence for SqlitePersister { + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.buffer + .store(wallet_id, changeset) + .map_err(PersistenceError::from)?; + match self.config.flush_mode { + FlushMode::Immediate => self.flush_inner(&wallet_id), + FlushMode::Manual => Ok(()), + } + } + + fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + self.flush_inner(&wallet_id) + } + + /// Load every wallet's start-state from disk. + /// + /// **Partial reconstruction caveat.** Today the implementation + /// populates `ClientStartState::platform_addresses` and leaves + /// `ClientStartState::wallets` empty — the latter requires an + /// upstream `Wallet::from_persisted` constructor that doesn't + /// exist yet. The data IS persisted in the SQLite schema and is + /// recoverable via direct queries; only the rehydrated + /// `(Wallet, ManagedWalletInfo)` pair is unavailable. + /// + /// Callers needing the partial-completion signal as a typed + /// value should call `inspect_counts` after a successful `load` + /// — non-zero counts in non-empty start-state buckets indicate + /// the sub-area is persisted but not yet reconstructed. The + /// `LOAD_UNIMPLEMENTED` constant names the affected + /// `ClientStartState` field paths. + /// + /// A `tracing::warn!` is emitted on every `load` call until the + /// reconstruction lands. + fn load(&self) -> Result { + let conn = self.conn().map_err(PersistenceError::from)?; + let mut state = ClientStartState::default(); + for wallet_id in schema::wallet_meta::list_ids(&conn).map_err(PersistenceError::from)? { + let addrs = schema::platform_addrs::load_state(&conn, &wallet_id) + .map_err(PersistenceError::from)?; + let count = schema::platform_addrs::count_per_wallet(&conn, &wallet_id) + .map_err(PersistenceError::from)?; + if count > 0 || addrs.sync_height > 0 || addrs.sync_timestamp > 0 { + state.platform_addresses.insert(wallet_id, addrs); + } + } + tracing::warn!( + unimplemented = ?LOAD_UNIMPLEMENTED, + "load() returned a partial ClientStartState — see SqlitePersister::load rustdoc" + ); + Ok(state) + } + + fn get_core_tx_record( + &self, + wallet_id: WalletId, + txid: &dashcore::Txid, + ) -> Result< + Option, + PersistenceError, + > { + let conn = self.conn().map_err(PersistenceError::from)?; + schema::core_state::get_tx_record(&conn, &wallet_id, txid).map_err(PersistenceError::from) + } +} + +// ----- Helpers ----- + +fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageError> { + if config.synchronous == Synchronous::Off { + return Err(WalletStorageError::ConfigInvalid { + reason: "synchronous=Off is rejected (data-loss footgun)", + }); + } + Ok(()) +} + +fn apply_pragmas( + conn: &mut Connection, + config: &SqlitePersisterConfig, +) -> Result<(), WalletStorageError> { + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.pragma_update(None, "journal_mode", config.journal_mode.pragma_value())?; + conn.pragma_update(None, "synchronous", config.synchronous.pragma_value())?; + let ms = safe_cast::u64_to_i64( + "busy_timeout_ms", + u64::try_from(config.busy_timeout.as_millis()).unwrap_or(i64::MAX as u64), + )?; + conn.pragma_update(None, "busy_timeout", ms)?; + Ok(()) +} + +/// Take a single auto-backup. Shared code path for open-time +/// (pre-migration), pre-restore, and pre-delete invocations. Returns +/// the absolute path written, or [`WalletStorageError::AutoBackupDisabled`] +/// when `auto_backup_dir` is `None`. +pub(crate) fn run_auto_backup( + src_conn: &Connection, + auto_backup_dir: Option<&Path>, + kind: BackupKind, + operation: AutoBackupOperation, +) -> Result, WalletStorageError> { + let Some(dir) = auto_backup_dir else { + return Err(WalletStorageError::AutoBackupDisabled { operation }); + }; + ensure_dir(dir)?; + let dest = dir.join(backup::auto_backup_filename(kind)); + backup::run_to(src_conn, &dest)?; + Ok(Some(dest)) +} + +fn ensure_dir(dir: &Path) -> Result<(), WalletStorageError> { + if !dir.exists() { + std::fs::create_dir_all(dir).map_err(|source| { + WalletStorageError::AutoBackupDirUnwritable { + dir: dir.to_path_buf(), + source, + } + })?; + } + // Probe writability via `tempfile::NamedTempFile` — unguessable + // name, no race against concurrent persister opens (CODE-008). + match tempfile::NamedTempFile::new_in(dir) { + Ok(_probe) => Ok(()), + Err(source) => Err(WalletStorageError::AutoBackupDirUnwritable { + dir: dir.to_path_buf(), + source, + }), + } +} + +fn count_pending( + conn: &mut Connection, + embedded: &[(i32, String)], +) -> Result { + let table_exists = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !table_exists { + return Ok(embedded.len()); + } + let applied: std::collections::HashSet = { + let mut stmt = conn.prepare("SELECT version FROM refinery_schema_history")?; + let rows: Result, _> = + stmt.query_map([], |row| row.get::<_, i64>(0))?.collect(); + rows? + }; + Ok(embedded + .iter() + .filter(|(v, _)| !applied.contains(&(*v as i64))) + .count()) +} + +fn current_schema_version(conn: &Connection) -> Result, WalletStorageError> { + let row = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get::<_, Option>(0), + ) + .optional()? + .flatten(); + Ok(row.map(|v| v as i32)) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs new file mode 100644 index 00000000000..fb73f16ccce --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -0,0 +1,133 @@ +//! `account_registrations` + `account_address_pools` writers. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::{AccountAddressPoolEntry, AccountRegistrationEntry}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +pub fn apply_registrations( + tx: &Transaction<'_>, + wallet_id: &WalletId, + entries: &[AccountRegistrationEntry], +) -> Result<(), WalletStorageError> { + for entry in entries { + let account_type = account_type_db_label(&entry.account_type); + let account_index = account_index(&entry.account_type); + // `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 payload = blob::encode(entry)?; + tx.execute( + "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", + params![ + wallet_id.as_slice(), + account_type, + i64::from(account_index), + payload, + ], + )?; + } + Ok(()) +} + +pub fn apply_pools( + tx: &Transaction<'_>, + wallet_id: &WalletId, + entries: &[AccountAddressPoolEntry], +) -> Result<(), WalletStorageError> { + 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)?; + tx.execute( + "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", + params![ + wallet_id.as_slice(), + account_type, + i64::from(account_index), + pool_type, + payload, + ], + )?; + } + Ok(()) +} + +/// Stable database label for an `AccountType` variant. +/// +/// Used for the `account_type` text column on `account_registrations`, +/// `account_address_pools`, and `core_derived_addresses`. The +/// `Debug` impl on `AccountType` is NOT a stable serialisation +/// format; this match is the contract. Variants identical in +/// label are distinguished by the companion `account_index` column. +/// +/// Adding a variant to upstream `AccountType` makes this match +/// exhaustive-check fail at compile time, forcing an explicit label +/// decision rather than silent garbage. +pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &'static str { + use key_wallet::account::AccountType; + match at { + AccountType::Standard { .. } => "standard", + AccountType::CoinJoin { .. } => "coinjoin", + AccountType::IdentityRegistration => "identity_registration", + AccountType::IdentityTopUp { .. } => "identity_topup", + AccountType::IdentityTopUpNotBoundToIdentity => "identity_topup_unbound", + AccountType::IdentityInvitation => "identity_invitation", + AccountType::AssetLockAddressTopUp => "asset_lock_address_topup", + AccountType::AssetLockShieldedAddressTopUp => "asset_lock_shielded_topup", + AccountType::ProviderVotingKeys => "provider_voting", + AccountType::ProviderOwnerKeys => "provider_owner", + AccountType::ProviderOperatorKeys => "provider_operator", + AccountType::ProviderPlatformKeys => "provider_platform", + AccountType::DashpayReceivingFunds { .. } => "dashpay_receiving", + AccountType::DashpayExternalAccount { .. } => "dashpay_external", + AccountType::PlatformPayment { .. } => "platform_payment", + } +} + +/// Stable database label for an `AddressPoolType` variant. +pub(crate) fn pool_type_db_label( + pool: &key_wallet::managed_account::address_pool::AddressPoolType, +) -> &'static str { + use key_wallet::managed_account::address_pool::AddressPoolType; + match pool { + AddressPoolType::External => "external", + AddressPoolType::Internal => "internal", + AddressPoolType::Absent => "absent", + AddressPoolType::AbsentHardened => "absent_hardened", + } +} + +fn account_index(at: &key_wallet::account::AccountType) -> u32 { + use key_wallet::account::AccountType; + match at { + AccountType::Standard { index, .. } => *index, + AccountType::CoinJoin { index } => *index, + AccountType::IdentityRegistration => 0, + AccountType::IdentityTopUp { registration_index } => *registration_index, + AccountType::IdentityTopUpNotBoundToIdentity => 0, + AccountType::IdentityInvitation => 0, + AccountType::AssetLockAddressTopUp => 0, + AccountType::AssetLockShieldedAddressTopUp => 0, + AccountType::ProviderVotingKeys => 0, + AccountType::ProviderOwnerKeys => 0, + AccountType::ProviderOperatorKeys => 0, + AccountType::ProviderPlatformKeys => 0, + AccountType::DashpayReceivingFunds { index, .. } => *index, + AccountType::DashpayExternalAccount { index, .. } => *index, + AccountType::PlatformPayment { account, .. } => *account, + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs new file mode 100644 index 00000000000..08687645d70 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -0,0 +1,113 @@ +//! `asset_locks` table writer + reader. +//! +//! Each row stores the lifecycle status as a string column for direct +//! SQL queries, plus a bincode-serde encoded `AssetLockEntry` in the +//! `lifecycle_blob` column. + +use std::collections::BTreeMap; + +use dashcore::OutPoint; +use rusqlite::{params, Connection, Transaction}; + +use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; +use platform_wallet::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &AssetLockChangeSet, +) -> Result<(), WalletStorageError> { + for (op, entry) in &cs.asset_locks { + let op_bytes = blob::encode_outpoint(op); + let lifecycle_blob = blob::encode(entry)?; + tx.execute( + "INSERT INTO asset_locks \ + (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(wallet_id, outpoint) DO UPDATE SET \ + status = excluded.status, \ + account_index = excluded.account_index, \ + identity_index = excluded.identity_index, \ + amount_duffs = excluded.amount_duffs, \ + lifecycle_blob = excluded.lifecycle_blob", + params![ + wallet_id.as_slice(), + &op_bytes[..], + status_str(&entry.status), + i64::from(entry.account_index), + i64::from(entry.identity_index), + crate::sqlite::util::safe_cast::u64_to_i64( + "asset_locks.amount_duffs", + entry.amount_duffs, + )?, + lifecycle_blob, + ], + )?; + } + for op in &cs.removed { + let op_bytes = blob::encode_outpoint(op); + tx.execute( + "DELETE FROM asset_locks WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &op_bytes[..]], + )?; + } + Ok(()) +} + +fn status_str(s: &AssetLockStatus) -> &'static str { + match s { + AssetLockStatus::Built => "built", + AssetLockStatus::Broadcast => "broadcast", + AssetLockStatus::InstantSendLocked => "is_locked", + AssetLockStatus::ChainLocked => "chain_locked", + } +} + +/// Return non-`Used` asset locks per wallet, bucketed by account +/// index. Every status variant the changeset writes is considered +/// "active": consumed locks leave via [`AssetLockChangeSet::removed`]. +pub fn list_active( + conn: &Connection, + wallet_id: &WalletId, +) -> Result>, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT outpoint, account_index, lifecycle_blob \ + FROM asset_locks WHERE wallet_id = ?1", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let op_bytes: Vec = row.get(0)?; + let account_index: i64 = row.get(1)?; + let blob_bytes: Vec = row.get(2)?; + Ok((op_bytes, account_index, blob_bytes)) + })?; + let mut out: BTreeMap> = BTreeMap::new(); + for r in rows { + let (op_bytes, account_index, blob_bytes) = r?; + let outpoint = blob::decode_outpoint(&op_bytes)?; + let entry: AssetLockEntry = blob::decode(&blob_bytes)?; + let tracked = TrackedAssetLock { + out_point: entry.out_point, + transaction: entry.transaction, + account_index: entry.account_index, + funding_type: entry.funding_type, + identity_index: entry.identity_index, + amount: entry.amount_duffs, + status: entry.status, + proof: entry.proof, + }; + 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, + })?; + out.entry(account_index) + .or_default() + .insert(outpoint, tracked); + } + Ok(out) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs new file mode 100644 index 00000000000..6dd21829299 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs @@ -0,0 +1,87 @@ +//! BLOB-column codec helpers. +//! +//! Thin error-mapping wrappers around `bincode::serde` so every +//! `_blob` column in the SQLite schema uses one encoding path. Schema +//! evolution is gated by the refinery migration version on the +//! database as a whole — there is no per-blob revision tag. +//! +//! [`encode_outpoint`] / [`decode_outpoint`] are a separate concern: +//! outpoints serve as primary-key fragments in typed columns, not as +//! blob payloads, and need a fixed on-disk layout for indexed lookups. + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::sqlite::error::WalletStorageError; + +/// Encode a serde-derived value into a `BLOB` payload. +pub fn encode(value: &T) -> Result, WalletStorageError> { + Ok(bincode::serde::encode_to_vec( + value, + bincode::config::standard(), + )?) +} + +/// Decode a `BLOB` payload back into a serde-derived value. +pub fn decode(blob: &[u8]) -> Result { + let (value, _) = bincode::serde::decode_from_slice(blob, bincode::config::standard())?; + Ok(value) +} + +/// Encode a `dashcore::OutPoint` (txid + vout) as 36 bytes. +pub fn encode_outpoint(op: &dashcore::OutPoint) -> [u8; 36] { + let mut out = [0u8; 36]; + out[..32].copy_from_slice(op.txid.as_ref()); + out[32..].copy_from_slice(&op.vout.to_le_bytes()); + out +} + +/// Decode a 36-byte outpoint. +pub fn decode_outpoint(bytes: &[u8]) -> Result { + use dashcore::hashes::Hash; + if bytes.len() != 36 { + return Err(WalletStorageError::blob_decode( + "outpoint must be exactly 36 bytes", + )); + } + let txid = dashcore::Txid::from_slice(&bytes[..32])?; + let mut vout_bytes = [0u8; 4]; + vout_bytes.copy_from_slice(&bytes[32..]); + Ok(dashcore::OutPoint { + txid, + vout: u32::from_le_bytes(vout_bytes), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] + struct Dummy { + a: u32, + b: String, + } + + #[test] + fn encode_decode_roundtrip() { + let value = Dummy { + a: 42, + b: "hello".into(), + }; + let blob = encode(&value).unwrap(); + let decoded: Dummy = decode(&blob).unwrap(); + assert_eq!(decoded, value); + } + + #[test] + fn outpoint_roundtrip() { + use dashcore::hashes::Hash; + let op = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([7u8; 32]), + vout: 9, + }; + let bytes = encode_outpoint(&op); + assert_eq!(decode_outpoint(&bytes).unwrap(), op); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs new file mode 100644 index 00000000000..05fc98a3c5c --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs @@ -0,0 +1,79 @@ +//! `contacts_sent` / `contacts_recv` / `contacts_established` writers. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::ContactChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &ContactChangeSet, +) -> Result<(), WalletStorageError> { + for (key, entry) in &cs.sent_requests { + let payload = blob::encode(entry)?; + tx.execute( + "INSERT INTO contacts_sent (wallet_id, owner_id, recipient_id, entry_blob) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id, owner_id, recipient_id) DO UPDATE SET entry_blob = excluded.entry_blob", + params![ + wallet_id.as_slice(), + key.owner_id.as_slice(), + key.recipient_id.as_slice(), + payload, + ], + )?; + } + for key in &cs.removed_sent { + tx.execute( + "DELETE FROM contacts_sent WHERE wallet_id = ?1 AND owner_id = ?2 AND recipient_id = ?3", + params![ + wallet_id.as_slice(), + key.owner_id.as_slice(), + key.recipient_id.as_slice(), + ], + )?; + } + for (key, entry) in &cs.incoming_requests { + let payload = blob::encode(entry)?; + tx.execute( + "INSERT INTO contacts_recv (wallet_id, owner_id, sender_id, entry_blob) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id, owner_id, sender_id) DO UPDATE SET entry_blob = excluded.entry_blob", + params![ + wallet_id.as_slice(), + key.owner_id.as_slice(), + key.sender_id.as_slice(), + payload, + ], + )?; + } + for key in &cs.removed_incoming { + tx.execute( + "DELETE FROM contacts_recv WHERE wallet_id = ?1 AND owner_id = ?2 AND sender_id = ?3", + params![ + wallet_id.as_slice(), + key.owner_id.as_slice(), + key.sender_id.as_slice(), + ], + )?; + } + for (key, established) in &cs.established { + let payload = blob::encode(established)?; + tx.execute( + "INSERT INTO contacts_established (wallet_id, owner_id, contact_id, entry_blob) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id, owner_id, contact_id) DO UPDATE SET entry_blob = excluded.entry_blob", + params![ + wallet_id.as_slice(), + key.owner_id.as_slice(), + key.recipient_id.as_slice(), + payload, + ], + )?; + } + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs new file mode 100644 index 00000000000..8e15f798a21 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -0,0 +1,256 @@ +//! Writers + readers for the `core_*` tables. + +use std::collections::BTreeMap; + +use rusqlite::{params, Connection, OptionalExtension, Transaction}; + +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::Utxo; +use platform_wallet::changeset::CoreChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +/// Apply a `CoreChangeSet` inside a transaction. +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &CoreChangeSet, +) -> Result<(), WalletStorageError> { + for record in &cs.records { + upsert_tx_record(tx, wallet_id, record)?; + } + for utxo in &cs.new_utxos { + upsert_utxo(tx, wallet_id, utxo, false)?; + } + for utxo in &cs.spent_utxos { + let op = blob::encode_outpoint(&utxo.outpoint); + let exists: bool = tx + .query_row( + "SELECT 1 FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &op[..]], + |_| Ok(true), + ) + .optional()? + .unwrap_or(false); + if exists { + tx.execute( + "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &op[..]], + )?; + } else { + upsert_utxo(tx, wallet_id, utxo, true)?; + } + } + for (txid, islock) in &cs.instant_locks_for_non_final_records { + let payload = blob::encode(islock)?; + tx.execute( + "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(wallet_id, txid) DO UPDATE SET islock_blob = excluded.islock_blob", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid), payload], + )?; + } + if cs.last_processed_height.is_some() || cs.synced_height.is_some() { + upsert_sync_state(tx, wallet_id, cs.last_processed_height, cs.synced_height)?; + } + for da in &cs.addresses_derived { + let account_type = crate::sqlite::schema::accounts::account_type_db_label(&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); + tx.execute( + "INSERT INTO core_derived_addresses (wallet_id, account_type, address, derivation_path, used) \ + VALUES (?1, ?2, ?3, ?4, ?5) \ + ON CONFLICT(wallet_id, account_type, address) DO UPDATE SET \ + derivation_path = excluded.derivation_path", + params![wallet_id.as_slice(), account_type, address, path, false], + )?; + } + Ok(()) +} + +fn upsert_tx_record( + tx: &Transaction<'_>, + wallet_id: &WalletId, + record: &TransactionRecord, +) -> Result<(), WalletStorageError> { + let block_info = record.block_info(); + let height = block_info.map(|b| i64::from(b.height())); + let block_hash = block_info.map(|b| AsRef::<[u8]>::as_ref(&b.block_hash()).to_vec()); + let block_time = block_info.map(|b| i64::from(b.timestamp())); + let finalized = block_info.is_some(); + let payload = blob::encode(record)?; + tx.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(wallet_id, txid) DO UPDATE SET \ + height = excluded.height, \ + block_hash = excluded.block_hash, \ + block_time = excluded.block_time, \ + finalized = excluded.finalized, \ + record_blob = excluded.record_blob", + params![ + wallet_id.as_slice(), + AsRef::<[u8]>::as_ref(&record.txid), + height, + block_hash, + block_time, + finalized, + payload, + ], + )?; + Ok(()) +} + +fn upsert_utxo( + tx: &Transaction<'_>, + wallet_id: &WalletId, + utxo: &Utxo, + spent: bool, +) -> Result<(), WalletStorageError> { + let op = blob::encode_outpoint(&utxo.outpoint); + tx.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) \ + ON CONFLICT(wallet_id, outpoint) DO UPDATE SET \ + value = excluded.value, \ + script = excluded.script, \ + height = excluded.height, \ + account_index = excluded.account_index, \ + spent = excluded.spent", + 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), + 0i64, // Utxo does not carry account_index; populated by derived-address lookup later. + spent, + ], + )?; + Ok(()) +} + +fn upsert_sync_state( + tx: &Transaction<'_>, + wallet_id: &WalletId, + last_processed: Option, + synced: Option, +) -> Result<(), WalletStorageError> { + // Monotonic-max semantics — keep the larger of (current, new). + let current = tx + .query_row( + "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| { + let lp: Option = row.get(0)?; + let sy: Option = row.get(1)?; + Ok((lp.map(|x| x as u32), sy.map(|x| x as u32))) + }, + ) + .optional()? + .unwrap_or((None, None)); + let lp = match (current.0, last_processed) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + }; + let sy = match (current.1, synced) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + }; + tx.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(wallet_id) DO UPDATE SET \ + last_processed_height = excluded.last_processed_height, \ + synced_height = excluded.synced_height", + params![wallet_id.as_slice(), lp.map(i64::from), sy.map(i64::from),], + )?; + Ok(()) +} + +/// Fetch a single transaction record by txid. Returns `Ok(None)` if +/// absent. +pub fn get_tx_record( + conn: &Connection, + wallet_id: &WalletId, + txid: &dashcore::Txid, +) -> Result, WalletStorageError> { + let row: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)], + |row| row.get(0), + ) + .optional()?; + match row { + None => Ok(None), + Some(payload) => Ok(Some(blob::decode(&payload)?)), + } +} + +/// Row representing one unspent UTXO. Used by tests that probe the +/// `core_utxos` table without going through full `Wallet` reconstruction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnspentRow { + pub outpoint: dashcore::OutPoint, + pub value: u64, + pub script: Vec, + pub height: Option, + pub account_index: u32, +} + +/// All UTXOs for a wallet that have not been spent yet, bucketed by +/// account index. Used by `load` and tests. +pub fn list_unspent_utxos( + conn: &Connection, + wallet_id: &WalletId, +) -> Result>, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT outpoint, value, script, height, account_index \ + FROM core_utxos WHERE wallet_id = ?1 AND spent = 0", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let op_bytes: Vec = row.get(0)?; + let value: i64 = row.get(1)?; + let script: Vec = row.get(2)?; + let height: Option = row.get(3)?; + let account_index: i64 = row.get(4)?; + Ok((op_bytes, value, script, height, account_index)) + })?; + let mut by_account: BTreeMap> = BTreeMap::new(); + for r in rows { + let (op_bytes, value, script_bytes, height, account_index) = r?; + let outpoint = blob::decode_outpoint(&op_bytes)?; + let value = crate::sqlite::util::safe_cast::i64_to_u64("core_utxos.value", value)?; + let height = match height { + None => None, + Some(h) => Some( + u32::try_from(h).map_err(|_| WalletStorageError::IntegerOverflow { + field: "core_utxos.height", + value: h as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?, + ), + }; + let account_index = + u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow { + field: "core_utxos.account_index", + value: account_index as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + let row = UnspentRow { + outpoint, + value, + script: script_bytes, + height, + account_index, + }; + by_account.entry(account_index).or_default().push(row); + } + Ok(by_account) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs new file mode 100644 index 00000000000..651406cfccc --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs @@ -0,0 +1,57 @@ +//! `dashpay_profiles` + `dashpay_payments_overlay` writers. + +use std::collections::BTreeMap; + +use rusqlite::{params, Transaction}; + +use dpp::prelude::Identifier; +use platform_wallet::wallet::identity::{DashPayProfile, PaymentEntry}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +/// Apply both dashpay overlays. +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + profiles: Option<&BTreeMap>>, + payments: Option<&BTreeMap>>, +) -> Result<(), WalletStorageError> { + if let Some(profiles) = profiles { + for (identity_id, profile) in profiles { + match profile { + None => { + tx.execute( + "DELETE FROM dashpay_profiles WHERE wallet_id = ?1 AND identity_id = ?2", + params![wallet_id.as_slice(), identity_id.as_slice()], + )?; + } + Some(p) => { + let payload = blob::encode(p)?; + tx.execute( + "INSERT INTO dashpay_profiles (wallet_id, identity_id, profile_blob) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(wallet_id, identity_id) DO UPDATE SET profile_blob = excluded.profile_blob", + params![wallet_id.as_slice(), identity_id.as_slice(), payload], + )?; + } + } + } + } + if let Some(payments) = payments { + for (identity_id, by_tx) in payments { + for (tx_id, entry) in by_tx { + let payload = blob::encode(entry)?; + tx.execute( + "INSERT INTO dashpay_payments_overlay \ + (wallet_id, identity_id, payment_id, overlay_blob) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id, identity_id, payment_id) DO UPDATE SET overlay_blob = excluded.overlay_blob", + params![wallet_id.as_slice(), identity_id.as_slice(), tx_id, payload], + )?; + } + } + } + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs new file mode 100644 index 00000000000..5f70dbef9ee --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -0,0 +1,102 @@ +//! `identities` table writer. + +use rusqlite::{params, Connection, Transaction}; + +use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &IdentityChangeSet, +) -> Result<(), WalletStorageError> { + for (id, entry) in &cs.identities { + let payload = blob::encode(entry)?; + tx.execute( + "INSERT INTO identities (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + VALUES (?1, ?2, ?3, ?4, 0) \ + ON CONFLICT(wallet_id, identity_id) DO UPDATE SET \ + wallet_index = excluded.wallet_index, \ + entry_blob = excluded.entry_blob, \ + tombstoned = 0", + params![ + wallet_id.as_slice(), + entry.identity_index.map(i64::from), + id.as_slice(), + payload, + ], + )?; + } + for id in &cs.removed { + tx.execute( + "UPDATE identities SET tombstoned = 1 WHERE wallet_id = ?1 AND identity_id = ?2", + params![wallet_id.as_slice(), id.as_slice()], + )?; + } + Ok(()) +} + +/// Decode a single `identities` row back to its [`IdentityEntry`]. +/// +/// Returns `Ok(None)` if no row matches. Tombstoned rows decode to +/// `Some(entry)`; the caller inspects the dedicated `tombstoned` +/// column to discriminate when needed. +pub fn fetch( + conn: &Connection, + wallet_id: &WalletId, + identity_id: &[u8; 32], +) -> Result, WalletStorageError> { + use rusqlite::OptionalExtension; + let row: Option> = conn + .query_row( + "SELECT entry_blob FROM identities WHERE wallet_id = ?1 AND identity_id = ?2", + params![wallet_id.as_slice(), &identity_id[..]], + |row| row.get(0), + ) + .optional()?; + match row { + None => Ok(None), + Some(payload) => Ok(Some(blob::decode(&payload)?)), + } +} + +/// Insert a stub identity row so identity_keys / dashpay_profiles can +/// reference it via the FK trigger. Used by tests that exercise +/// identity_keys persistence without going through the full identity +/// flow. The stub row carries a `null`-encoded `IdentityEntry` so the +/// `entry_blob` column always decodes — callers wanting real data +/// overwrite via [`apply`]. +pub fn ensure_exists( + conn: &Connection, + wallet_id: &WalletId, + identity_id: &[u8; 32], +) -> Result<(), WalletStorageError> { + use dpp::prelude::Identifier; + use platform_wallet::wallet::identity::IdentityStatus; + + let stub = IdentityEntry { + id: Identifier::from(*identity_id), + balance: 0, + revision: 0, + identity_index: None, + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Unknown, + wallet_id: None, + dashpay_profile: None, + dashpay_payments: Default::default(), + }; + let payload = blob::encode(&stub)?; + conn.execute( + "INSERT OR IGNORE INTO identities \ + (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + VALUES (?1, NULL, ?2, ?3, 0)", + params![wallet_id.as_slice(), &identity_id[..], payload], + )?; + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs new file mode 100644 index 00000000000..c03de6ec9e9 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -0,0 +1,110 @@ +//! `identity_keys` table writer (PUBLIC material only — see NFR-10). +//! +//! `IdentityKeyEntry`'s `public_key: dpp::IdentityPublicKey` uses +//! `#[serde(tag = "$formatVersion")]` on the parent enum, which +//! bincode-serde rejects (it requires `deserialize_any`). The other +//! fields are plain serde-compatible types. To keep the +//! "one blob per row" property we transcribe the entry into a wire +//! shape where the public key is bincode-2-native-encoded (the dpp +//! types derive `Encode`/`Decode`) and the surrounding fields ride +//! the bincode-serde encoder. The shape is documented at +//! [`IdentityKeyWire`]. + +use rusqlite::{params, Transaction}; +use serde::{Deserialize, Serialize}; + +use dpp::identity::{IdentityPublicKey, KeyID}; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; + +/// On-disk wire shape for `IdentityKeyEntry`. The `public_key` field +/// is pre-encoded via bincode 2's native `Encode/Decode` impls on +/// `dpp::IdentityPublicKey` so bincode-serde doesn't trip on dpp's +/// `serde(tag = ...)` representation. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct IdentityKeyWire { + identity_id: Identifier, + key_id: KeyID, + public_key_bincode: Vec, + public_key_hash: [u8; 20], + wallet_id: Option<[u8; 32]>, + derivation_indices: Option, +} + +impl IdentityKeyWire { + fn from_entry(entry: &IdentityKeyEntry) -> Result { + let pk = bincode::encode_to_vec(&entry.public_key, bincode::config::standard())?; + Ok(Self { + identity_id: entry.identity_id, + key_id: entry.key_id, + public_key_bincode: pk, + public_key_hash: entry.public_key_hash, + wallet_id: entry.wallet_id, + derivation_indices: entry.derivation_indices, + }) + } + + fn into_entry(self) -> Result { + let (public_key, _): (IdentityPublicKey, usize) = + bincode::decode_from_slice(&self.public_key_bincode, bincode::config::standard())?; + Ok(IdentityKeyEntry { + identity_id: self.identity_id, + key_id: self.key_id, + public_key, + public_key_hash: self.public_key_hash, + wallet_id: self.wallet_id, + derivation_indices: self.derivation_indices, + }) + } +} + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &IdentityKeysChangeSet, +) -> Result<(), WalletStorageError> { + for ((identity_id, key_id), entry) in &cs.upserts { + let wire = IdentityKeyWire::from_entry(entry)?; + let entry_blob = blob::encode(&wire)?; + tx.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) \ + ON CONFLICT(wallet_id, identity_id, key_id) DO UPDATE SET \ + public_key_blob = excluded.public_key_blob, \ + public_key_hash = excluded.public_key_hash, \ + derivation_blob = NULL", + params![ + wallet_id.as_slice(), + identity_id.as_slice(), + i64::from(*key_id), + entry_blob, + &entry.public_key_hash[..], + ], + )?; + } + for (identity_id, key_id) in &cs.removed { + tx.execute( + "DELETE FROM identity_keys \ + WHERE wallet_id = ?1 AND identity_id = ?2 AND key_id = ?3", + params![ + wallet_id.as_slice(), + identity_id.as_slice(), + i64::from(*key_id), + ], + )?; + } + Ok(()) +} + +/// Decode an `identity_keys.public_key_blob` cell back to the entry. +pub fn decode_entry(payload: &[u8]) -> Result { + let wire: IdentityKeyWire = blob::decode(payload)?; + wire.into_entry() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs new file mode 100644 index 00000000000..3379d44ad01 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -0,0 +1,51 @@ +//! Per-area SQLite writers + readers. +//! +//! Each submodule owns one table or a small cluster (e.g. `contacts` +//! owns three). Writers take a `&rusqlite::Transaction` and an already +//! resolved sub-changeset; readers take `&rusqlite::Connection`. +//! +//! Encoding policy: scalars that fan out to per-row indexes go into +//! typed SQLite columns (heights, hashes, outpoints, flags). The +//! `_blob` columns carry the full sub-changeset entry encoded with +//! `bincode::serde::encode_to_vec` against the serde-derived types in +//! `platform-wallet` — see [`blob::encode`] / [`blob::decode`]. +//! Schema evolution is gated by the refinery migration version on +//! the database; individual blobs have no inline revision tag. + +pub mod accounts; +pub mod asset_locks; +pub mod blob; +pub mod contacts; +pub mod core_state; +pub mod dashpay; +pub mod identities; +pub mod identity_keys; +pub mod platform_addrs; +pub mod token_balances; +pub mod wallet_meta; + +/// Every per-wallet table — used by `delete_wallet` to count + cascade +/// row removal and by `inspect` for the table summary. `wallet_metadata` +/// is the parent and listed first; everything after it depends on the +/// parent row (cascade triggers wired in `V001__initial.rs`). +pub const PER_WALLET_TABLES: &[&str] = &[ + "wallet_metadata", + "account_registrations", + "account_address_pools", + "core_transactions", + "core_utxos", + "core_instant_locks", + "core_derived_addresses", + "core_sync_state", + "identities", + "identity_keys", + "contacts_sent", + "contacts_recv", + "contacts_established", + "platform_addresses", + "platform_address_sync", + "asset_locks", + "token_balances", + "dashpay_profiles", + "dashpay_payments_overlay", +]; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs new file mode 100644 index 00000000000..651c351fb33 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs @@ -0,0 +1,194 @@ +//! `platform_addresses` + `platform_address_sync` writers. + +use rusqlite::{params, Connection, OptionalExtension, Transaction}; + +use dash_sdk::platform::address_sync::AddressFunds; +use key_wallet::PlatformP2PKHAddress; +use platform_wallet::changeset::PlatformAddressChangeSet; +use platform_wallet::changeset::PlatformAddressSyncStartState; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::util::safe_cast; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &PlatformAddressChangeSet, +) -> Result<(), WalletStorageError> { + for entry in &cs.addresses { + tx.execute( + "INSERT INTO platform_addresses \ + (wallet_id, account_index, address_index, address, balance, nonce) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(wallet_id, address) DO UPDATE SET \ + account_index = excluded.account_index, \ + address_index = excluded.address_index, \ + balance = excluded.balance, \ + nonce = excluded.nonce", + params![ + wallet_id.as_slice(), + i64::from(entry.account_index), + i64::from(entry.address_index), + entry.address.as_bytes(), + safe_cast::u64_to_i64("platform_addresses.balance", entry.funds.balance)?, + i64::from(entry.funds.nonce), + ], + )?; + } + if cs.sync_height.is_some() + || cs.sync_timestamp.is_some() + || cs.last_known_recent_block.is_some() + { + let current: Option<(i64, i64, i64)> = tx + .query_row( + "SELECT sync_height, sync_timestamp, last_known_recent_block \ + FROM platform_address_sync WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let (cur_h, cur_t, cur_r) = match current { + Some((h, t, r)) => ( + safe_cast::i64_to_u64("platform_address_sync.sync_height", h)?, + safe_cast::i64_to_u64("platform_address_sync.sync_timestamp", t)?, + safe_cast::i64_to_u64("platform_address_sync.last_known_recent_block", r)?, + ), + None => (0u64, 0u64, 0u64), + }; + let h = cs.sync_height.map(|x| x.max(cur_h)).unwrap_or(cur_h); + let t = cs.sync_timestamp.map(|x| x.max(cur_t)).unwrap_or(cur_t); + let r = cs + .last_known_recent_block + .map(|x| x.max(cur_r)) + .unwrap_or(cur_r); + tx.execute( + "INSERT INTO platform_address_sync \ + (wallet_id, sync_height, sync_timestamp, last_known_recent_block) \ + VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(wallet_id) DO UPDATE SET \ + sync_height = excluded.sync_height, \ + sync_timestamp = excluded.sync_timestamp, \ + last_known_recent_block = excluded.last_known_recent_block", + params![ + wallet_id.as_slice(), + safe_cast::u64_to_i64("platform_address_sync.sync_height", h)?, + safe_cast::u64_to_i64("platform_address_sync.sync_timestamp", t)?, + safe_cast::u64_to_i64("platform_address_sync.last_known_recent_block", r)?, + ], + )?; + } + Ok(()) +} + +/// Row from `platform_addresses` keyed by wallet for tests/load. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformAddressRow { + pub account_index: u32, + pub address_index: u32, + pub address: PlatformP2PKHAddress, + pub funds: AddressFunds, +} + +pub fn list_per_wallet( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let mut stmt = conn.prepare( + "SELECT account_index, address_index, address, balance, nonce \ + FROM platform_addresses WHERE wallet_id = ?1 \ + ORDER BY account_index, address_index, address", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let account_index: i64 = row.get(0)?; + let address_index: i64 = row.get(1)?; + let address_bytes: Vec = row.get(2)?; + let balance: i64 = row.get(3)?; + let nonce: i64 = row.get(4)?; + Ok((account_index, address_index, address_bytes, balance, nonce)) + })?; + let mut out = Vec::new(); + for r in rows { + let (account_index, address_index, address_bytes, balance, nonce) = r?; + if address_bytes.len() != 20 { + return Err(WalletStorageError::blob_decode( + "platform_addresses.address column is not 20 bytes", + )); + } + let mut hash160 = [0u8; 20]; + hash160.copy_from_slice(&address_bytes); + let balance = safe_cast::i64_to_u64("platform_addresses.balance", balance)?; + let nonce = u32::try_from(nonce).map_err(|_| WalletStorageError::IntegerOverflow { + field: "platform_addresses.nonce", + value: nonce as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + let account_index = + u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow { + field: "platform_addresses.account_index", + value: account_index as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + let address_index = + u32::try_from(address_index).map_err(|_| WalletStorageError::IntegerOverflow { + field: "platform_addresses.address_index", + value: address_index as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + out.push(PlatformAddressRow { + account_index, + address_index, + address: PlatformP2PKHAddress::new(hash160), + funds: AddressFunds { balance, nonce }, + }); + } + Ok(out) +} + +/// Build `PlatformAddressSyncStartState` for a wallet. The +/// `per_account` portion is left at its `Default` value because +/// reconstructing `PerWalletPlatformAddressState` requires xpubs the +/// persister doesn't currently round-trip into the live provider — the +/// load-side wiring upstream is the consumer of this struct. +pub fn load_state( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let row: Option<(i64, i64, i64)> = conn + .query_row( + "SELECT sync_height, sync_timestamp, last_known_recent_block \ + FROM platform_address_sync WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let (h, t, r) = match row { + Some((h, t, r)) => ( + safe_cast::i64_to_u64("platform_address_sync.sync_height", h)?, + safe_cast::i64_to_u64("platform_address_sync.sync_timestamp", t)?, + safe_cast::i64_to_u64("platform_address_sync.last_known_recent_block", r)?, + ), + None => (0u64, 0u64, 0u64), + }; + Ok(PlatformAddressSyncStartState { + per_account: Default::default(), + sync_height: h, + sync_timestamp: t, + last_known_recent_block: r, + }) +} + +/// Total `platform_addresses` row count per wallet — used by tests +/// that want a stable lower-bound check without re-deriving the +/// address. +pub fn count_per_wallet( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let n: i64 = conn.query_row( + "SELECT COUNT(*) FROM platform_addresses WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + )?; + Ok(usize::try_from(n).unwrap_or(usize::MAX)) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs new file mode 100644 index 00000000000..4f05425b3d7 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs @@ -0,0 +1,46 @@ +//! `token_balances` table writer. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::TokenBalanceChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::util::safe_cast; + +pub fn apply( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &TokenBalanceChangeSet, +) -> Result<(), WalletStorageError> { + let now = chrono::Utc::now().timestamp(); + for ((identity_id, token_id), balance) in &cs.balances { + tx.execute( + "INSERT INTO token_balances \ + (wallet_id, identity_id, token_id, balance, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5) \ + ON CONFLICT(wallet_id, identity_id, token_id) DO UPDATE SET \ + balance = excluded.balance, \ + updated_at = excluded.updated_at", + params![ + wallet_id.as_slice(), + identity_id.as_slice(), + token_id.as_slice(), + safe_cast::u64_to_i64("token_balances.balance", *balance)?, + now, + ], + )?; + } + for (identity_id, token_id) in &cs.removed_balances { + tx.execute( + "DELETE FROM token_balances \ + WHERE wallet_id = ?1 AND identity_id = ?2 AND token_id = ?3", + params![ + wallet_id.as_slice(), + identity_id.as_slice(), + token_id.as_slice() + ], + )?; + } + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs new file mode 100644 index 00000000000..c830ca251c0 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs @@ -0,0 +1,104 @@ +//! `wallet_metadata` writer + helpers. + +use rusqlite::{params, Connection, Transaction}; + +use platform_wallet::changeset::WalletMetadataEntry; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; + +/// Insert / replace a `wallet_metadata` row. +pub fn upsert( + tx: &Transaction<'_>, + wallet_id: &WalletId, + entry: &WalletMetadataEntry, +) -> Result<(), WalletStorageError> { + let network = network_to_str(entry.network); + tx.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, ?2, ?3) \ + ON CONFLICT(wallet_id) DO UPDATE SET network = excluded.network, \ + birth_height = excluded.birth_height", + params![wallet_id.as_slice(), network, entry.birth_height], + )?; + Ok(()) +} + +/// Ensure a `wallet_metadata` parent row exists for the given id. Used +/// by tests that exercise persistence without going through registration. +/// +/// Idempotent — silently a no-op when the row already exists. Defaults +/// `network = "testnet"`, `birth_height = 0` (the same fall-back the +/// SPV scan uses when the chain tip is unknown). +pub fn ensure_exists(conn: &Connection, wallet_id: &WalletId) -> Result<(), WalletStorageError> { + conn.execute( + "INSERT OR IGNORE INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, ?2, ?3)", + params![wallet_id.as_slice(), "testnet", 0i64], + )?; + Ok(()) +} + +/// All known wallet ids (used by `delete_wallet`, `load`, `inspect`). +pub fn list_ids(conn: &Connection) -> Result, WalletStorageError> { + let mut stmt = conn.prepare("SELECT wallet_id FROM wallet_metadata ORDER BY wallet_id")?; + let rows = stmt.query_map([], |row| { + let bytes: Vec = row.get(0)?; + let mut wid = [0u8; 32]; + if bytes.len() == 32 { + wid.copy_from_slice(&bytes); + } + Ok(wid) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} + +/// Lookup `(network, birth_height)` for a wallet, if known. +pub fn fetch( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let mut stmt = + conn.prepare("SELECT network, birth_height FROM wallet_metadata WHERE wallet_id = ?1")?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + if let Some(row) = rows.next()? { + let network: String = row.get(0)?; + let height: i64 = row.get(1)?; + Ok(Some((network, height as u32))) + } else { + Ok(None) + } +} + +/// Delete a wallet_metadata row (cascade triggers fire). +pub fn delete(tx: &Transaction<'_>, wallet_id: &WalletId) -> Result { + let n = tx.execute( + "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + )?; + Ok(n) +} + +fn network_to_str(net: key_wallet::Network) -> &'static str { + match net { + key_wallet::Network::Mainnet => "mainnet", + key_wallet::Network::Testnet => "testnet", + key_wallet::Network::Devnet => "devnet", + key_wallet::Network::Regtest => "regtest", + } +} + +/// Inverse of [`network_to_str`]. +pub fn parse_network(s: &str) -> Option { + match s { + "mainnet" => Some(key_wallet::Network::Mainnet), + "testnet" => Some(key_wallet::Network::Testnet), + "devnet" => Some(key_wallet::Network::Devnet), + "regtest" => Some(key_wallet::Network::Regtest), + _ => None, + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs new file mode 100644 index 00000000000..921ef15f9a4 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs @@ -0,0 +1,4 @@ +//! Shared internal helpers (safe casts, file permissions, etc.). + +pub mod permissions; +pub mod safe_cast; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/permissions.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/permissions.rs new file mode 100644 index 00000000000..b1d30a342a3 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/permissions.rs @@ -0,0 +1,23 @@ +//! SEC-004 / SEC-011: chmod helpers for newly created DB files. +//! +//! Restricts the on-disk SQLite files (live DB, backup copies, restored +//! DB) to owner-only on Unix so the mode never depends on the calling +//! process's umask. Windows has no equivalent permission model here and +//! is a no-op. + +use std::path::Path; + +use crate::sqlite::error::WalletStorageError; + +/// Apply owner-only (`0o600`) permissions to `path` on Unix. +/// No-op on non-Unix platforms. +#[allow(unused_variables)] // `path` is unused on non-Unix. +pub fn apply_secure_permissions(path: &Path) -> Result<(), WalletStorageError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(path, perms)?; + } + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs new file mode 100644 index 00000000000..c02632913b2 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs @@ -0,0 +1,98 @@ +//! Safe integer conversions for the SQLite `INTEGER` column boundary. +//! +//! SQLite's `INTEGER` affinity is `i64`. Rust's wallet types (credits +//! balances, durations cast to milliseconds, monotonic-max heights, +//! token balances) are `u64`. Naively `as i64` casting wraps values +//! ≥ `i64::MAX` to negative numbers and silently sign-extends them +//! back to large `u64` on read. +//! +//! Every cross-boundary cast in the writer / reader paths runs through +//! one of these helpers and produces a typed +//! [`WalletStorageError::IntegerOverflow`] on out-of-range input. +//! `clippy::cast_possible_wrap` and `cast_sign_loss` warnings stay +//! allowed crate-wide because many in-crate casts are bounded (e.g. +//! `u8` tags, `u32` indices ≤ `i32::MAX`); the contract is that +//! *durable boundary casts* go through this module. + +use crate::sqlite::error::WalletStorageError; + +/// The target type whose range was exceeded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum SafeCastTarget { + #[error("i64")] + I64, + #[error("u64")] + U64, +} + +/// Cast `value: u64` to `i64`, surfacing +/// [`WalletStorageError::IntegerOverflow`] when the value exceeds +/// `i64::MAX`. +/// +/// `field` is a compile-time identifier (e.g. `"asset_locks.amount_duffs"`) +/// naming the column so the resulting error is actionable. +pub fn u64_to_i64(field: &'static str, value: u64) -> Result { + i64::try_from(value).map_err(|_| WalletStorageError::IntegerOverflow { + field, + value, + target: SafeCastTarget::I64, + }) +} + +/// Cast `value: i64` to `u64`, surfacing +/// [`WalletStorageError::IntegerOverflow`] when the database stored +/// a negative value (possible if a previous build wrote a wrapped +/// value before this helper existed). +pub fn i64_to_u64(field: &'static str, value: i64) -> Result { + u64::try_from(value).map_err(|_| WalletStorageError::IntegerOverflow { + field, + // For negative inputs the wrapped representation is what we + // surface — the operator looks at the original bits, not the + // post-cast u64 garbage. + value: value as u64, + target: SafeCastTarget::U64, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn u64_to_i64_happy_path() { + assert_eq!(u64_to_i64("x", 0).unwrap(), 0); + assert_eq!(u64_to_i64("x", i64::MAX as u64).unwrap(), i64::MAX); + } + + #[test] + fn u64_to_i64_overflow() { + let err = u64_to_i64("balance", u64::MAX).unwrap_err(); + assert!(matches!( + err, + WalletStorageError::IntegerOverflow { + field: "balance", + value: u64::MAX, + target: SafeCastTarget::I64, + } + )); + } + + #[test] + fn i64_to_u64_happy_path() { + assert_eq!(i64_to_u64("x", 0).unwrap(), 0); + assert_eq!(i64_to_u64("x", i64::MAX).unwrap(), i64::MAX as u64); + } + + #[test] + fn i64_to_u64_overflow_on_negative() { + let err = i64_to_u64("balance", -1).unwrap_err(); + assert!(matches!( + err, + WalletStorageError::IntegerOverflow { + field: "balance", + target: SafeCastTarget::U64, + .. + } + )); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/common/mod.rs b/packages/rs-platform-wallet-storage/tests/common/mod.rs new file mode 100644 index 00000000000..6885e2f9532 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/common/mod.rs @@ -0,0 +1,66 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Shared test helpers for the SQLite persister integration tests. + +#![allow(dead_code)] + +use std::path::PathBuf; + +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::Connection; + +pub use platform_wallet_storage::{FlushMode, SqlitePersister, SqlitePersisterConfig}; + +/// Open an empty temp directory + persister for one test. Returns the +/// persister, the keep-alive `tempfile::TempDir`, and the DB path. +pub fn fresh_persister() -> (SqlitePersister, tempfile::TempDir, PathBuf) { + fresh_persister_with_mode(FlushMode::Immediate) +} + +pub fn fresh_persister_with_mode(mode: FlushMode) -> (SqlitePersister, tempfile::TempDir, PathBuf) { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("wallet.db"); + let cfg = SqlitePersisterConfig::new(&path).with_flush_mode(mode); + let p = SqlitePersister::open(cfg).expect("open persister"); + (p, tmp, path) +} + +/// Wallet id helper. +pub fn wid(byte: u8) -> WalletId { + [byte; 32] +} + +/// Open a read-only side connection — used by tests that probe the DB +/// while the persister still owns the write conn. +pub fn ro_conn(path: &std::path::Path) -> Connection { + Connection::open_with_flags( + path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .expect("open ro conn") +} + +/// Insert a stub `wallet_metadata` row so child writes pass the FK +/// trigger. Bypasses the buffer/flush layer — tests use this when they +/// want to exercise a single sub-changeset writer in isolation. +pub fn ensure_wallet_meta(persister: &SqlitePersister, wallet_id: &WalletId) { + use rusqlite::params; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT OR IGNORE INTO wallet_metadata (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + params![wallet_id.as_slice()], + ) + .expect("ensure wallet_metadata"); +} + +/// Echo a simple `store` + `flush` of an arbitrary changeset. +pub fn store_and_flush( + persister: &SqlitePersister, + wallet_id: WalletId, + cs: platform_wallet::changeset::PlatformWalletChangeSet, +) { + persister.store(wallet_id, cs).expect("store"); + persister.flush(wallet_id).expect("flush"); +} diff --git a/packages/rs-platform-wallet-storage/tests/secrets_scan.rs b/packages/rs-platform-wallet-storage/tests/secrets_scan.rs new file mode 100644 index 00000000000..a2248b35d2b --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/secrets_scan.rs @@ -0,0 +1,108 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Schema-file substring scan for forbidden secret-material tokens +//! (the load-bearing test for the NFR-10 / SECRETS.md boundary). +//! +//! The persister never stores mnemonics / seeds / private keys. +//! This test grep-scans every file under `src/sqlite/schema/` and +//! `migrations/` for ASCII substrings associated with secret material. +//! A new column, blob field, or comment that uses `private`, +//! `mnemonic`, `seed`, `xpriv`, or `secret` breaks the test, forcing +//! the author to rename or add an allow-list entry with rationale. +//! +//! Out of scope by design: files in `src/sqlite/` outside of +//! `schema/` (`persister.rs`, `backup.rs`, `buffer.rs`, `config.rs`, +//! `error.rs`, `migrations.rs`, `util/`) are NOT scanned. They never +//! define database columns and may legitimately reference the +//! forbidden tokens in doc comments. The future `src/secrets/` +//! submodule slot is exempt for the same reason. +//! +//! The check is intentionally string-level: it does not parse SQL or +//! Rust. A column literally named `private_X` is the kind of mistake +//! we want to catch; legitimate uses inside doc comments are +//! allow-listed via the `ALLOWLIST` constant below. + +use std::path::Path; + +const FORBIDDEN: &[&str] = &["private", "mnemonic", "seed", "xpriv", "secret"]; + +/// Doc-comment / identifier substrings we deliberately want to +/// permit even though they contain a forbidden token. Keep this list +/// tiny — each entry is a string that must appear verbatim in the +/// offending line for it to be ignored. +const ALLOWLIST: &[&str] = &[ + // `IdentityPublicKey` blob column carries only PUBLIC material; + // the doc comment says so explicitly. Allow-listing the phrase + // means future contributors can still surface the boundary. + "PUBLIC material only", + "No private bytes", + "no private key", + "private-key bytes", + "public_key_blob", + "public material", + "do not derive private keys", + "private keys are NOT", +]; + +fn line_is_allowlisted(line: &str) -> bool { + ALLOWLIST.iter().any(|needle| line.contains(needle)) +} + +fn scan_dir(dir: &Path, offenders: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + scan_dir(&p, offenders); + continue; + } + if !p + .extension() + .is_some_and(|e| e == "rs" || e == "sql" || e == "md") + { + continue; + } + // Skip the test file itself; it intentionally lists the + // forbidden tokens. + if p.file_name().and_then(|s| s.to_str()) == Some("secrets_scan.rs") { + continue; + } + let body = match std::fs::read_to_string(&p) { + Ok(s) => s, + Err(_) => continue, + }; + for (idx, line) in body.lines().enumerate() { + let lower = line.to_ascii_lowercase(); + for needle in FORBIDDEN { + if lower.contains(needle) && !line_is_allowlisted(line) { + offenders.push(format!( + "{}:{}: contains `{needle}` — {}", + p.display(), + idx + 1, + line.trim() + )); + } + } + } + } +} + +#[test] +fn no_secret_substrings_in_schema_or_migrations() { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut offenders = Vec::new(); + // `src/sqlite/schema` (SQLite-backend column definitions and blob + // encoders) and `migrations/` (refinery DDL) are the entire + // persistence surface for non-secret material. `src/secrets/` is + // exempt by design — that submodule WILL legitimately mention + // `private`, `mnemonic`, `seed` once the SecretStore lands. + scan_dir(&manifest.join("src/sqlite/schema"), &mut offenders); + scan_dir(&manifest.join("migrations"), &mut offenders); + assert!( + offenders.is_empty(), + "forbidden secret-material tokens found in schema files (see SECRETS.md):\n{}", + offenders.join("\n") + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs b/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs new file mode 100644 index 00000000000..26a72389bb1 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs @@ -0,0 +1,159 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-050..TC-055 — automatic backups. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet_storage::{ + AutoBackupOperation, SqlitePersister, SqlitePersisterConfig, WalletStorageError, +}; + +/// TC-050: brand-new DB does NOT produce a pre-migration backup. +#[test] +fn tc050_brand_new_db_skips_pre_migration_backup() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path); + let dir = cfg.auto_backup_dir.clone().unwrap(); + let _p = SqlitePersister::open(cfg).unwrap(); + if dir.exists() { + let leftover = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("pre-migration")) + .count(); + assert_eq!( + leftover, 0, + "fresh DB should not produce pre-migration backups" + ); + } +} + +/// TC-051: delete_wallet writes a pre-delete backup before deleting. +#[test] +fn tc051_pre_delete_backup_taken() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE0); + ensure_wallet_meta(&persister, &w); + let report = persister.delete_wallet(w).expect("delete_wallet"); + let backup_path = report.backup_path.expect("backup path present"); + assert!(backup_path.exists(), "backup file does not exist on disk"); + let name = backup_path.file_name().unwrap().to_string_lossy(); + assert!( + name.starts_with("pre-delete-") && name.ends_with(".db"), + "unexpected pre-delete filename: {name}" + ); +} + +/// TC-052: delete_wallet with auto_backup_dir = None returns AutoBackupDisabled. +#[test] +fn tc052_delete_wallet_auto_backup_disabled() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path).with_auto_backup_dir(None); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xE1); + ensure_wallet_meta(&persister, &w); + let err = persister.delete_wallet(w); + assert!( + matches!( + err, + Err(WalletStorageError::AutoBackupDisabled { + operation: AutoBackupOperation::DeleteWallet + }) + ), + "expected AutoBackupDisabled, got {err:?}" + ); + // Rows for `w` should still be present. + let conn = persister.lock_conn_for_test(); + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 1); +} + +/// TC-054 (partial): unwritable auto-backup dir surfaces AutoBackupDirUnwritable. +#[test] +fn tc054_unwritable_auto_backup_dir() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let unwritable = tmp.path().join("read-only-dir"); + std::fs::create_dir(&unwritable).unwrap(); + // chmod 0500 (r-x------) — we cannot write to it. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&unwritable).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&unwritable, perms).unwrap(); + } + let cfg = SqlitePersisterConfig::new(&path).with_auto_backup_dir(Some(unwritable.clone())); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xE2); + ensure_wallet_meta(&persister, &w); + let err = persister.delete_wallet(w); + #[cfg(unix)] + { + assert!( + matches!(err, Err(WalletStorageError::AutoBackupDirUnwritable { .. })), + "expected AutoBackupDirUnwritable, got {err:?}" + ); + // Wallet still intact. + let conn = persister.lock_conn_for_test(); + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 1); + // Cleanup so tempdir can drop. + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&unwritable).unwrap().permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(&unwritable, perms); + } + #[cfg(not(unix))] + { + // Non-unix: chmod is best-effort; we accept either outcome. + let _ = (err, unwritable); + } +} + +/// TC-055: auto-backups respect the same retention as manual backups. +#[test] +fn tc055_auto_backups_subject_to_retention() { + let (persister, _tmp, _path) = fresh_persister(); + let dir = persister.config_for_test().auto_backup_dir.clone().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + // Drop in five `pre-delete-*` fixture files. + for i in 0..5 { + let name = format!( + "pre-delete-{}-{}.db", + hex::encode([i; 32]), + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::hours(i as i64)) + .unwrap() + .format("%Y%m%dT%H%M%SZ") + ); + std::fs::write(dir.join(name), b"x").unwrap(); + } + let report = persister + .prune_backups( + &dir, + platform_wallet_storage::RetentionPolicy { + keep_last_n: Some(2), + max_age: None, + }, + ) + .unwrap(); + assert_eq!(report.kept, 2); + assert_eq!(report.removed.len(), 3); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs b/packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs new file mode 100644 index 00000000000..f2858375d34 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_backup_restore.rs @@ -0,0 +1,169 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-031..TC-039 — online backup, restore source validation, retention. + +mod common; + +use std::fs; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::{RetentionPolicy, SqlitePersister, WalletStorageError}; + +fn seed_one_row(persister: &SqlitePersister, w: &[u8; 32]) { + ensure_wallet_meta(persister, w); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(5), + last_processed_height: Some(5), + ..Default::default() + }); + persister.store(*w, cs).unwrap(); +} + +/// TC-031: backup_to(directory) produces a wallet-.db file. +#[test] +fn tc031_backup_directory_form() { + let (persister, tmp, _path) = fresh_persister(); + seed_one_row(&persister, &wid(0xD0)); + let out_dir = tmp.path().join("backups"); + fs::create_dir(&out_dir).unwrap(); + let written = persister.backup_to(&out_dir).expect("backup_to"); + assert!(written.starts_with(&out_dir)); + let name = written.file_name().unwrap().to_string_lossy().into_owned(); + assert!(name.starts_with("wallet-") && name.ends_with(".db")); + // Open the produced file and confirm it has the schema. + let src = + rusqlite::Connection::open_with_flags(&written, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .unwrap(); + let check: String = src + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .unwrap(); + assert_eq!(check, "ok"); +} + +/// TC-032: backup_to(explicit file path) writes to the exact path. +#[test] +fn tc032_backup_file_form() { + let (persister, tmp, _path) = fresh_persister(); + seed_one_row(&persister, &wid(0xD1)); + let target = tmp.path().join("explicit-name.db"); + let written = persister.backup_to(&target).unwrap(); + assert_eq!(written, target.canonicalize().unwrap_or(target.clone())); + assert!(target.exists()); + // Refuses overwrite. + let err = persister.backup_to(&target); + assert!( + matches!(err, Err(WalletStorageError::BackupDestinationExists { .. })), + "expected BackupDestinationExists, got {err:?}" + ); +} + +/// TC-035 (subset): restore_from round-trips state via the on-disk backup. +#[test] +fn tc035_restore_roundtrip() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xD2); + seed_one_row(&persister, &w); + // Take a backup. + let backup_path = persister.backup_to(tmp.path()).unwrap(); + // Mutate the source — make synced_height a different value. + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + synced_height: Some(999), + last_processed_height: Some(999), + ..Default::default() + }); + persister.store(w, cs).unwrap(); + drop(persister); + // Restore. + // Tests pass through `restore_from_skip_backup` — simpler than + // threading an auto_backup_dir through fixtures. + SqlitePersister::restore_from_skip_backup(&path, &backup_path).expect("restore_from"); + // Reopen and check the synced height reverted to 5. + let cfg = platform_wallet_storage::SqlitePersisterConfig::new(&path); + let p2 = SqlitePersister::open(cfg).unwrap(); + let conn = p2.lock_conn_for_test(); + let h: i64 = conn + .query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(h, 5); +} + +/// TC-036: restore source missing schema_history is rejected. +#[test] +fn tc036_restore_missing_schema_history() { + let tmp = tempfile::tempdir().unwrap(); + let fake_src = tmp.path().join("empty.db"); + rusqlite::Connection::open(&fake_src).unwrap(); + let dest = tmp.path().join("dest.db"); + fs::write(&dest, b"placeholder").unwrap(); + let err = SqlitePersister::restore_from_skip_backup(&dest, &fake_src); + assert!(matches!(err, Err(WalletStorageError::SchemaHistoryMissing))); +} + +/// TC-037: corrupt source rejected. +#[test] +fn tc037_restore_corrupt_source() { + let tmp = tempfile::tempdir().unwrap(); + let corrupt = tmp.path().join("corrupt.db"); + fs::write(&corrupt, b"not a sqlite file ABCDEF").unwrap(); + let dest = tmp.path().join("dest.db"); + fs::write(&dest, b"placeholder").unwrap(); + let err = SqlitePersister::restore_from_skip_backup(&dest, &corrupt); + assert!( + matches!( + err, + Err(WalletStorageError::IntegrityCheckFailed { .. }) + | Err(WalletStorageError::IntegrityCheckRunFailed { .. }) + | Err(WalletStorageError::SourceOpenFailed { .. }) + | Err(WalletStorageError::Sqlite(_)) + ), + "expected IntegrityCheckFailed / IntegrityCheckRunFailed / SourceOpenFailed / Sqlite, got {err:?}" + ); +} + +/// TC-038: prune retention AND-semantics. +#[test] +fn tc038_prune_and_semantics() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + // Write 5 fake backup files with mtimes 1d/7d/14d/30d/60d ago. + let day = std::time::Duration::from_secs(86_400); + let now = std::time::SystemTime::now(); + let ages = [1u64, 7, 14, 30, 60]; + let mut files = Vec::new(); + for age in ages { + let name = format!( + "wallet-{}.db", + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::days(age as i64)) + .unwrap() + .format("%Y%m%dT%H%M%SZ") + ); + let path = dir.join(&name); + fs::write(&path, b"x").unwrap(); + let mtime = now - day * age as u32; + let _ = filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(mtime)); + files.push(path); + } + let (persister, _tmp_pers, _path) = fresh_persister(); + let report = persister + .prune_backups( + dir, + RetentionPolicy { + keep_last_n: Some(3), + max_age: Some(day * 20), + }, + ) + .unwrap(); + // Files with ages 30d and 60d (older than 20d) should be removed. + assert_eq!(report.removed.len(), 2); + assert_eq!(report.kept, 3); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs b/packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs new file mode 100644 index 00000000000..ada5a7ba383 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_buffer_semantics.rs @@ -0,0 +1,339 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-016..TC-024 (subset) — buffer + flush semantics. +//! +//! Some adversarial cases (TC-021 partial-failure, TC-024 mid-flush +//! failure) require a fault-injection seam that the production code +//! exposes only behind `#[cfg(test)]`. The seam is documented in +//! `persister.rs::lock_conn_for_test`; tests that need to inject a +//! failure poison the DB through that handle and verify rollback. + +mod common; + +use std::collections::BTreeMap; + +use common::{ensure_wallet_meta, fresh_persister, fresh_persister_with_mode, ro_conn, wid}; + +use dashcore::hashes::Hash; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::FlushMode; + +fn core_with_height(synced_height: u32, last_processed_height: u32) -> CoreChangeSet { + CoreChangeSet { + synced_height: Some(synced_height), + last_processed_height: Some(last_processed_height), + ..Default::default() + } +} + +fn changeset(core: CoreChangeSet) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(core), + ..Default::default() + } +} + +/// TC-017: Manual mode defers I/O. +#[test] +fn tc017_manual_defers_io() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(1); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(5, 5))) + .unwrap(); + // Without a flush, the row count for core_sync_state for `w` is 0. + let n: i64 = ro_conn(&path) + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 0); + persister.flush(w).unwrap(); + let n: i64 = ro_conn(&path) + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 1); +} + +/// TC-018: Immediate mode flushes inline. +#[test] +fn tc018_immediate_flushes_inline() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Immediate); + let w = wid(2); + ensure_wallet_meta(&persister, &w); + persister + .store(w, changeset(core_with_height(5, 5))) + .unwrap(); + let n: i64 = ro_conn(&path) + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 1); +} + +/// TC-019: commit_writes flushes every dirty wallet. +#[test] +fn tc019_commit_writes_flushes_dirty() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let a = wid(0x10); + let b = wid(0x20); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + persister + .store(a, changeset(core_with_height(5, 5))) + .unwrap(); + persister + .store(b, changeset(core_with_height(7, 7))) + .unwrap(); + persister.commit_writes().unwrap(); + let conn = ro_conn(&path); + let count_for = |id: &[u8; 32]| -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(count_for(&a), 1); + assert_eq!(count_for(&b), 1); +} + +/// TC-020: commit_writes in Immediate mode is a no-op. +#[test] +fn tc020_commit_writes_noop_in_immediate() { + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Immediate); + persister.commit_writes().unwrap(); +} + +/// TC-022: flush(A) doesn't write or clear B's buffer. +#[test] +fn tc022_flush_is_scoped() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let a = wid(0x30); + let b = wid(0x31); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + persister + .store(a, changeset(core_with_height(3, 3))) + .unwrap(); + persister + .store(b, changeset(core_with_height(4, 4))) + .unwrap(); + persister.flush(a).unwrap(); + let conn = ro_conn(&path); + let count_for = |id: &[u8; 32]| -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(count_for(&a), 1); + assert_eq!(count_for(&b), 0); + persister.flush(b).unwrap(); + assert_eq!(count_for(&b), 1); +} + +/// TC-016: property — N stores then flush == one merged store. +/// +/// We use the monotonic-max merge on sync heights as the oracle. +#[test] +fn tc016_buffer_merge_oracle_smoke() { + use proptest::prelude::*; + let strategy = proptest::collection::vec((0u32..1_000_000, 0u32..1_000_000), 1..6); + proptest!(ProptestConfig::with_cases(64), |(heights in strategy)| { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0x40); + ensure_wallet_meta(&persister, &w); + for &(sp, lp) in &heights { + persister.store(w, changeset(core_with_height(sp, lp))).unwrap(); + } + // Read back the persisted heights. + let conn = persister.lock_conn_for_test(); + let (synced, lp): (Option, Option) = conn + .query_row( + "SELECT synced_height, last_processed_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + drop(conn); + let expected_synced = heights.iter().map(|(s, _)| *s).max().unwrap_or(0); + let expected_lp = heights.iter().map(|(_, l)| *l).max().unwrap_or(0); + prop_assert_eq!(synced.unwrap_or(0) as u32, expected_synced); + prop_assert_eq!(lp.unwrap_or(0) as u32, expected_lp); + }); +} + +/// TC-001 (subset) — get_core_tx_record round-trips through `core_transactions`. +#[test] +fn tc001_get_core_tx_record_roundtrip() { + use dashcore::blockdata::transaction::Transaction; + use dashcore::Txid; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0x50); + ensure_wallet_meta(&persister, &w); + let txid = Txid::from_byte_array([9u8; 32]); + let dummy_tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + dummy_tx, + key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([3u8; 32]), + 1735689600, + )), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 100, + ); + record.txid = txid; + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + records: vec![record], + ..Default::default() + }); + persister.store(w, cs).unwrap(); + let got = persister.get_core_tx_record(w, &txid).unwrap(); + let got = got.expect("record present"); + assert_eq!(got.txid, txid); + let info = got.context.block_info().expect("block info present"); + assert_eq!(info.height(), 42); + let unknown = dashcore::Txid::from_byte_array([0u8; 32]); + assert!(persister.get_core_tx_record(w, &unknown).unwrap().is_none()); +} + +/// TC-015: two wallets coexist without key collisions. +#[test] +fn tc015_two_wallets_in_one_db() { + let (persister, _tmp, _path) = fresh_persister(); + let a = wid(0xA1); + let b = wid(0xB2); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + // Distinct height per wallet so we can distinguish. + persister + .store(a, changeset(core_with_height(11, 11))) + .unwrap(); + persister + .store(b, changeset(core_with_height(22, 22))) + .unwrap(); + let conn = persister.lock_conn_for_test(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM core_sync_state", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 2); + let h_a: i64 = conn + .query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![a.as_slice()], + |row| row.get(0), + ) + .unwrap(); + let h_b: i64 = conn + .query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![b.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(h_a, 11); + assert_eq!(h_b, 22); +} + +/// TC-023: one `flush(wallet_id)` produces exactly one SQLite +/// transaction. +/// +/// `rusqlite::Connection::commit_hook` registers a callback that fires +/// after every successful commit. We register it on the persister's +/// write connection, then drive a flush whose changeset touches +/// multiple sub-changesets (core sync state + wallet metadata + +/// platform addresses + token balances). The hook MUST fire exactly +/// once for the duration of the flush call, regardless of how many +/// tables were written. +#[test] +fn tc023_one_flush_is_one_transaction() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use dpp::prelude::Identifier; + use key_wallet::Network; + use platform_wallet::changeset::{TokenBalanceChangeSet, WalletMetadataEntry}; + + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0x90); + ensure_wallet_meta(&persister, &w); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(core_with_height(7, 7)); + cs.wallet_metadata = Some(WalletMetadataEntry { + network: Network::Testnet, + birth_height: 1, + }); + let mut balances = BTreeMap::new(); + let owner = Identifier::from([0xA1u8; 32]); + let token = Identifier::from([0xA2u8; 32]); + balances.insert((owner, token), 9u64); + cs.token_balances = Some(TokenBalanceChangeSet { + balances, + ..Default::default() + }); + persister.store(w, cs).unwrap(); + + // Install the commit hook AFTER buffering (which only mutates + // memory) and BEFORE flush. + let commits = Arc::new(AtomicUsize::new(0)); + { + let commits_clone = Arc::clone(&commits); + let conn = persister.lock_conn_for_test(); + conn.commit_hook(Some(move || { + commits_clone.fetch_add(1, Ordering::SeqCst); + false + })) + .expect("install commit hook"); + } + + persister.flush(w).unwrap(); + + // Remove the hook so the persister is reusable elsewhere. + { + let conn = persister.lock_conn_for_test(); + conn.commit_hook(None:: bool>) + .expect("remove commit hook"); + } + + assert_eq!( + commits.load(Ordering::SeqCst), + 1, + "expected exactly one COMMIT for the flush, got {}", + commits.load(Ordering::SeqCst) + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs b/packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs new file mode 100644 index 00000000000..a79310e54ce --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_cli_smoke.rs @@ -0,0 +1,187 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-056..TC-075 — CLI smoke tests. + +use std::process::Command; + +use assert_cmd::cargo::CommandCargoExt; + +fn cli() -> Command { + Command::cargo_bin("platform-wallet-storage").expect("bin built") +} + +/// TC-056: migrate on a fresh DB prints `applied: ` then `applied: 0`. +#[test] +fn tc056_migrate_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + let out = cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .unwrap(); + assert!(out.status.success(), "first migrate failed: {out:?}"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.starts_with("applied: ") && stdout.trim() != "applied: 0", + "unexpected first-run stdout: {stdout}" + ); + let out2 = cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .unwrap(); + assert!(out2.status.success(), "second migrate failed"); + let stdout2 = String::from_utf8_lossy(&out2.stdout); + assert_eq!(stdout2.trim(), "applied: 0"); +} + +/// TC-062: restore without --yes refuses (exit 2). +#[test] +fn tc062_restore_without_yes_refuses() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .expect("migrate ran"); + let fake_src = tmp.path().join("not-a-backup.db"); + std::fs::write(&fake_src, b"x").unwrap(); + let out = cli() + .args([ + "--db", + db.to_str().unwrap(), + "restore", + "--from", + fake_src.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "expected exit 2; got {:?} stderr={}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); +} + +/// TC-065: prune without --keep-last or --max-age is a usage error. +#[test] +fn tc065_prune_requires_a_rule() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + let dir = tmp.path().join("bk"); + std::fs::create_dir(&dir).unwrap(); + let out = cli() + .args([ + "--db", + db.to_str().unwrap(), + "prune", + "--in", + dir.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); +} + +/// TC-070: invalid wallet-id format exits 2. +#[test] +fn tc070_inspect_invalid_wallet_id() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .expect("migrate ran"); + for bad in ["zzzz", "00"] { + let out = cli() + .args(["--db", db.to_str().unwrap(), "inspect", "--wallet-id", bad]) + .output() + .unwrap(); + assert_eq!( + out.status.code(), + Some(2), + "expected exit 2 for `{bad}`; got {:?}", + out.status.code() + ); + } +} + +/// TC-072: delete-wallet without --yes exits 2. +#[test] +fn tc072_delete_wallet_without_yes_refuses() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .expect("migrate ran"); + let out = cli() + .args([ + "--db", + db.to_str().unwrap(), + "delete-wallet", + "--wallet-id", + &"aa".repeat(32), + ]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); +} + +/// TC-068: inspect TSV format prints `table\tcount` lines. +#[test] +fn tc068_inspect_tsv() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .expect("migrate ran"); + let out = cli() + .args(["--db", db.to_str().unwrap(), "inspect", "--format", "tsv"]) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + assert!( + lines.len() >= 18, + "expected ≥18 lines of TSV, got {}", + lines.len() + ); + for line in lines { + let cols: Vec<&str> = line.split('\t').collect(); + assert_eq!(cols.len(), 2, "bad TSV line: `{line}`"); + let n: i64 = cols[1].parse().expect(line); + assert!(n >= 0); + } +} + +/// TC-059: backup --out writes a timestamped file. +#[test] +fn tc059_backup_dir() { + let tmp = tempfile::tempdir().unwrap(); + let db = tmp.path().join("w.db"); + cli() + .args(["--db", db.to_str().unwrap(), "migrate"]) + .output() + .expect("migrate ran"); + let out_dir = tmp.path().join("bk"); + std::fs::create_dir(&out_dir).unwrap(); + let out = cli() + .args([ + "--db", + db.to_str().unwrap(), + "backup", + "--out", + out_dir.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(out.status.success(), "backup failed: {out:?}"); + let stdout = String::from_utf8_lossy(&out.stdout); + let path = stdout.trim(); + assert!(path.ends_with(".db")); + assert!(std::path::Path::new(path).exists()); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs new file mode 100644 index 00000000000..b7ce12a55e4 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -0,0 +1,23 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-076, TC-077, TC-078 — compile-time assertions. + +use std::sync::Arc; + +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use static_assertions::assert_impl_all; + +assert_impl_all!(SqlitePersister: Send, Sync, PlatformWalletPersistence); + +/// TC-078: SqlitePersister fits behind Arc. +#[test] +fn tc078_object_safety() { + fn accepts(_: Arc) {} + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path); + let p = SqlitePersister::open(cfg).unwrap(); + let arc: Arc = Arc::new(p); + accepts(arc); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs new file mode 100644 index 00000000000..0fe9bcad952 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs @@ -0,0 +1,113 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-045..TC-048 — foreign-key enforcement (emulated via triggers). + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; + +/// TC-045: PRAGMA foreign_keys is ON on the connection. +#[test] +fn tc045_foreign_keys_on() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let fk: i64 = conn + .query_row("SELECT * FROM pragma_foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!(fk, 1, "foreign_keys pragma not ON"); +} + +/// TC-046: insert into a child table without a wallet_metadata parent fails. +#[test] +fn tc046_orphan_child_insert_rejected() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + use rusqlite::params; + let res = conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, NULL, NULL)", + params![[99u8; 32].as_slice()], + ); + let err = res.unwrap_err().to_string(); + assert!( + err.contains("FOREIGN KEY"), + "expected FOREIGN KEY constraint failure, got `{err}`" + ); +} + +/// TC-047: deleting wallet_metadata cascades. +#[test] +fn tc047_delete_wallet_cascade() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xC0); + ensure_wallet_meta(&persister, &w); + // Insert one row into a child table. + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ + VALUES (?1, 1, 1)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + } + let report = persister.delete_wallet(w).expect("delete_wallet"); + assert_eq!(report.wallet_id, w); + assert!(report.backup_path.is_some()); + assert!( + report + .rows_removed_per_table + .get("wallet_metadata") + .copied() + .unwrap_or(0) + >= 1 + ); + let conn = persister.lock_conn_for_test(); + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(n, 0); +} + +/// TC-048: deleting a core_transactions row sets `spent_in_txid = NULL` on UTXOs. +#[test] +fn tc048_setnull_on_tx_delete() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xC2); + ensure_wallet_meta(&persister, &w); + let conn = persister.lock_conn_for_test(); + let txid = [4u8; 32]; + let outpoint = vec![0u8; 36]; + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, 1, NULL, NULL, 0, X'01')", + rusqlite::params![w.as_slice(), &txid[..]], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 100, X'00', NULL, 0, 1, ?3)", + rusqlite::params![w.as_slice(), &outpoint, &txid[..]], + ) + .unwrap(); + conn.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + rusqlite::params![w.as_slice(), &txid[..]], + ) + .unwrap(); + let spent_in: Option> = conn + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![w.as_slice(), &outpoint], + |row| row.get(0), + ) + .unwrap(); + assert!( + spent_in.is_none(), + "spent_in_txid should have been set to NULL" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs new file mode 100644 index 00000000000..6e1635acd6a --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs @@ -0,0 +1,172 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-040, TC-043, TC-044 — load() reconstructs the wired-up subset. +//! +//! TC-041 / TC-042 (wallets[*].utxos / .unused_asset_locks) are blocked +//! on upstream `Wallet::from_persisted` — the persister stores the data +//! (verified via direct SQL probes) but cannot reconstruct the +//! `Wallet` + `ManagedWalletInfo` pair that `ClientWalletStartState` +//! requires. The unwired fields are listed in +//! `persister::LOAD_UNIMPLEMENTED` and surfaced via a `tracing::warn!` +//! on every `load`. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dash_sdk::platform::address_sync::AddressFunds; +use key_wallet::PlatformP2PKHAddress; +use platform_wallet::changeset::{ + PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; + +fn entry( + wallet_id: [u8; 32], + account_index: u32, + address_index: u32, + byte: u8, +) -> PlatformAddressBalanceEntry { + PlatformAddressBalanceEntry { + wallet_id, + account_index, + address_index, + address: PlatformP2PKHAddress::new([byte; 20]), + funds: AddressFunds { + balance: address_index as u64 * 100, + nonce: address_index, + }, + } +} + +/// TC-040: load() reconstructs platform_addresses per wallet. +#[test] +fn tc040_load_platform_addresses() { + let (persister, _tmp, _path) = fresh_persister(); + let a = wid(0xAA); + let b = wid(0xBB); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + let mut cs_a = PlatformWalletChangeSet::default(); + cs_a.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![entry(a, 0, 0, 0x11), entry(a, 0, 1, 0x12)], + sync_height: Some(10), + ..Default::default() + }); + let mut cs_b = PlatformWalletChangeSet::default(); + cs_b.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![entry(b, 0, 0, 0x21)], + sync_height: Some(20), + ..Default::default() + }); + persister.store(a, cs_a).unwrap(); + persister.store(b, cs_b).unwrap(); + drop(persister); + let tmp_dir = _tmp; + let path = tmp_dir.path().join("wallet.db"); + let p2 = platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(&path), + ) + .unwrap(); + let state = p2.load().unwrap(); + assert_eq!(state.platform_addresses.len(), 2); + assert_eq!(state.platform_addresses[&a].sync_height, 10); + assert_eq!(state.platform_addresses[&b].sync_height, 20); +} + +/// TC-043: non-wired-up sub-areas are written to disk (verified by +/// direct SQL probes) but do not surface in the load result. +/// +/// Constructs non-empty `ContactChangeSet` and `TokenBalanceChangeSet` +/// payloads — `is_empty()` returns false on either, so the buffer +/// flushes them — then asserts both `contacts_sent` and +/// `token_balances` rows are present in SQLite after a reopen, while +/// `ClientStartState.platform_addresses` stays empty for the wallet +/// (no platform-address activity was stored). +#[test] +fn tc043_non_wired_up_persisted_but_not_returned() { + use dpp::prelude::Identifier; + use platform_wallet::changeset::{ + ContactChangeSet, ContactRequestEntry, SentContactRequestKey, TokenBalanceChangeSet, + }; + use platform_wallet::wallet::identity::ContactRequest; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xCC); + let owner = Identifier::from([0x11; 32]); + let recipient = Identifier::from([0x22; 32]); + let token = Identifier::from([0x33; 32]); + ensure_wallet_meta(&persister, &w); + // Identity row required for the contacts/dashpay FK triggers if + // any are wired into contacts_*; the contacts_* tables themselves + // only check the wallet_metadata parent today, so we don't need + // an identity row for this test — but we'd add one here if the + // trigger set grew. + let mut sent_requests = std::collections::BTreeMap::new(); + sent_requests.insert( + SentContactRequestKey { + owner_id: owner, + recipient_id: recipient, + }, + ContactRequestEntry { + request: ContactRequest { + sender_id: owner, + recipient_id: recipient, + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_account_label: None, + encrypted_public_key: Vec::new(), + auto_accept_proof: None, + core_height_created_at: 0, + created_at: 0, + }, + }, + ); + let mut balances = std::collections::BTreeMap::new(); + balances.insert((owner, token), 42u64); + let cs = PlatformWalletChangeSet { + contacts: Some(ContactChangeSet { + sent_requests, + ..Default::default() + }), + token_balances: Some(TokenBalanceChangeSet { + balances, + ..Default::default() + }), + ..Default::default() + }; + persister.store(w, cs).unwrap(); + drop(persister); + + // Reopen against the same DB and confirm the rows are durable on + // disk + the load result is platform-address-empty for this wallet. + let p2 = platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(&path), + ) + .unwrap(); + let state = p2.load().unwrap(); + assert!( + !state.platform_addresses.contains_key(&w), + "no platform-address activity was stored — wallet must be absent" + ); + drop(p2); + + let conn = common::ro_conn(&path); + let sent: i64 = conn + .query_row( + "SELECT COUNT(*) FROM contacts_sent WHERE wallet_id = ?1 AND owner_id = ?2 AND recipient_id = ?3", + rusqlite::params![w.as_slice(), owner.as_slice(), recipient.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(sent, 1, "contacts_sent row missing after reopen"); + let tokens: i64 = conn + .query_row( + "SELECT COUNT(*) FROM token_balances WHERE wallet_id = ?1 AND identity_id = ?2 AND token_id = ?3", + rusqlite::params![w.as_slice(), owner.as_slice(), token.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tokens, 1, "token_balances row missing after reopen"); + drop(tmp); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs new file mode 100644 index 00000000000..5482f2142c3 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -0,0 +1,213 @@ +#![allow(clippy::field_reassign_with_default)] + +//! TC-025..TC-030, TC-028, TC-044 — migration discovery and reach. + +mod common; + +use common::fresh_persister; +use platform_wallet_storage::sqlite::migrations as mig; + +/// TC-025: every embedded migration corresponds to a file in `migrations/`. +#[test] +fn tc025_embedded_migrations_match_files() { + let embedded = mig::embedded_migrations(); + assert!(!embedded.is_empty(), "no migrations embedded"); + let crate_root = env!("CARGO_MANIFEST_DIR"); + let on_disk: Vec<_> = std::fs::read_dir(format!("{crate_root}/migrations")) + .expect("read migrations dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with('V') && n.ends_with(".rs")) + .collect(); + assert_eq!( + embedded.len(), + on_disk.len(), + "embedded vs on-disk count mismatch: {embedded:?} vs {on_disk:?}" + ); + for (v, name) in &embedded { + let expected_padded = format!("V{:03}__{}.rs", v, name); + let expected_plain = format!("V{}__{}.rs", v, name); + assert!( + on_disk + .iter() + .any(|f| f == &expected_padded || f == &expected_plain), + "no on-disk file for migration V{v} {name} \ + (expected {expected_padded} or {expected_plain})" + ); + } +} + +/// TC-026: fresh DB ends at latest schema version. +#[test] +fn tc026_fresh_db_at_latest() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let max: Option = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .unwrap(); + let highest_embedded = mig::embedded_migrations() + .iter() + .map(|(v, _)| *v as i64) + .max() + .unwrap(); + assert_eq!(max, Some(highest_embedded)); +} + +/// TC-027: every declared table is creatable and accepts a minimal row +/// (parent first, then children). +#[test] +fn tc027_smoke_insert_every_table() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + use rusqlite::params; + let wallet_id = [42u8; 32]; + + conn.execute( + "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet_id.as_slice()], + ) + .unwrap(); + let identity_id = [7u8; 32]; + conn.execute( + "INSERT INTO identities (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + VALUES (?1, NULL, ?2, X'01', 0)", + params![wallet_id.as_slice(), identity_id.as_slice()], + ) + .unwrap(); + let outpoint = vec![0u8; 36]; + let txid = vec![0u8; 32]; + let cases: &[(&str, &str, &[&dyn rusqlite::ToSql])] = &[ + ( + "account_registrations", + "INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'Standard', 0, X'00')", + &[&wallet_id.as_slice()], + ), + ( + "account_address_pools", + "INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'Standard', 0, 'External', X'00')", + &[&wallet_id.as_slice()], + ), + ( + "core_transactions", + "INSERT INTO core_transactions (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) VALUES (?1, ?2, NULL, NULL, NULL, 0, X'00')", + &[&wallet_id.as_slice(), &txid], + ), + ( + "core_utxos", + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) VALUES (?1, ?2, 0, X'00', NULL, 0, 0, NULL)", + &[&wallet_id.as_slice(), &outpoint], + ), + ( + "core_instant_locks", + "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, X'00')", + &[&wallet_id.as_slice(), &txid], + ), + ( + "core_derived_addresses", + "INSERT INTO core_derived_addresses (wallet_id, account_type, address, derivation_path, used) VALUES (?1, 'Standard', 'addr', '', 0)", + &[&wallet_id.as_slice()], + ), + ( + "core_sync_state", + "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, NULL, NULL)", + &[&wallet_id.as_slice()], + ), + ( + "identity_keys", + "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_sent", + "INSERT INTO contacts_sent (wallet_id, owner_id, recipient_id, entry_blob) VALUES (?1, ?2, ?3, X'00')", + &[&wallet_id.as_slice(), &identity_id.as_slice(), &[1u8; 32].as_slice()], + ), + ( + "contacts_recv", + "INSERT INTO contacts_recv (wallet_id, owner_id, sender_id, entry_blob) VALUES (?1, ?2, ?3, X'00')", + &[&wallet_id.as_slice(), &identity_id.as_slice(), &[2u8; 32].as_slice()], + ), + ( + "contacts_established", + "INSERT INTO contacts_established (wallet_id, owner_id, contact_id, entry_blob) VALUES (?1, ?2, ?3, X'00')", + &[&wallet_id.as_slice(), &identity_id.as_slice(), &[3u8; 32].as_slice()], + ), + ( + "platform_addresses", + "INSERT INTO platform_addresses (wallet_id, account_index, address_index, address, balance, nonce) VALUES (?1, 0, 0, X'0000000000000000000000000000000000000000', 0, 0)", + &[&wallet_id.as_slice()], + ), + ( + "platform_address_sync", + "INSERT INTO platform_address_sync (wallet_id, sync_height, sync_timestamp, last_known_recent_block) VALUES (?1, 0, 0, 0)", + &[&wallet_id.as_slice()], + ), + ( + "asset_locks", + "INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'built', 0, 0, 0, X'00')", + &[&wallet_id.as_slice(), &outpoint], + ), + ( + "token_balances", + "INSERT INTO token_balances (wallet_id, identity_id, token_id, balance, updated_at) VALUES (?1, ?2, ?3, 0, 0)", + &[&wallet_id.as_slice(), &identity_id.as_slice(), &[5u8; 32].as_slice()], + ), + ( + "dashpay_profiles", + "INSERT INTO dashpay_profiles (wallet_id, identity_id, profile_blob) VALUES (?1, ?2, X'00')", + &[&wallet_id.as_slice(), &identity_id.as_slice()], + ), + ( + "dashpay_payments_overlay", + "INSERT INTO dashpay_payments_overlay (wallet_id, identity_id, payment_id, overlay_blob) VALUES (?1, ?2, 'pay1', X'00')", + &[&wallet_id.as_slice(), &identity_id.as_slice()], + ), + ]; + for (table, sql, params) in cases { + conn.execute(sql, *params).expect(table); + let n: i64 = conn + .query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE wallet_id = ?1"), + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert!(n >= 1, "{table} insert did not land"); + } +} + +/// TC-028: re-open is idempotent. +#[test] +fn tc028_idempotent_reopen() { + let (persister, tmp, path) = fresh_persister(); + drop(persister); + let cfg = platform_wallet_storage::SqlitePersisterConfig::new(&path); + let _p2 = platform_wallet_storage::SqlitePersister::open(cfg).expect("reopen"); + drop(tmp); +} + +/// TC-029: append-only migration hash. +/// +/// The hash is computed at runtime from the embedded list. Because this +/// test belongs to the migration drift policy, we assert the list is +/// non-empty and the hash is stable across successive calls — not a +/// pinned value (which would force a churn on every committed migration). +#[test] +fn tc029_migration_fingerprint_stable() { + let a = mig::embedded_migrations_fingerprint(); + let b = mig::embedded_migrations_fingerprint(); + assert_eq!(a, b); + assert!(!mig::embedded_migrations().is_empty()); +} + +/// TC-044: load() on empty post-migrate DB is empty. +#[test] +fn tc044_load_empty_is_empty() { + let (persister, _tmp, _path) = fresh_persister(); + let state = platform_wallet::changeset::PlatformWalletPersistence::load(&persister).unwrap(); + assert!(state.is_empty()); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs new file mode 100644 index 00000000000..ddd9e9fc0e3 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -0,0 +1,490 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Per-sub-changeset round-trip tests. +//! +//! Now that `platform-wallet`'s `serde` feature is active, every +//! changeset blob is a single bincode-serde payload — these tests +//! store a non-trivial entry, reopen the persister, decode the blob, +//! and assert structural equality (where the type allows) or +//! field-level equality (where it doesn't, e.g. `TransactionRecord` +//! which is `Debug + Clone` only upstream). +//! +//! TC-001 (CoreChangeSet records) is exercised through the trait +//! method in `sqlite_buffer_semantics.rs::tc001_get_core_tx_record_roundtrip`. +//! TC-015 (multi-wallet coexistence) lives there too. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::Network; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet_storage::{ + SqlitePersister, SqlitePersisterConfig, Synchronous, WalletStorageError, +}; + +/// TC-005: sync heights round-trip with monotonic-max merge. +#[test] +fn tc005_sync_heights_roundtrip() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xF0); + ensure_wallet_meta(&persister, &w); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + last_processed_height: Some(100), + synced_height: Some(95), + ..Default::default() + }); + persister.store(w, cs).unwrap(); + let mut cs = PlatformWalletChangeSet::default(); + cs.core = Some(CoreChangeSet { + last_processed_height: Some(120), + synced_height: Some(100), + ..Default::default() + }); + persister.store(w, cs).unwrap(); + let conn = persister.lock_conn_for_test(); + let (lp, sy): (i64, i64) = conn + .query_row( + "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(lp, 120); + assert_eq!(sy, 100); +} + +/// TC-013: wallet_metadata round-trip. +#[test] +fn tc013_wallet_metadata_roundtrip() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xF1); + ensure_wallet_meta(&persister, &w); + let cs = PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + birth_height: 12345, + }), + ..Default::default() + }; + persister.store(w, cs).unwrap(); + let conn = persister.lock_conn_for_test(); + let (network, birth_height): (String, i64) = conn + .query_row( + "SELECT network, birth_height FROM wallet_metadata WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(network, "testnet"); + assert_eq!(birth_height, 12345); +} + +/// TC-079: synchronous=Off is rejected at open with a typed error. +#[test] +fn tc079_synchronous_off_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let mut cfg = SqlitePersisterConfig::new(&path); + cfg.synchronous = Synchronous::Off; + let err = SqlitePersister::open(cfg); + let matched = matches!(err.as_ref(), Err(WalletStorageError::ConfigInvalid { .. })); + assert!( + matched, + "expected ConfigInvalid, got error = {:?}", + err.as_ref().err() + ); + assert!( + !path.exists(), + "DB should not be created when config is invalid" + ); +} + +/// TC-080: SqlitePersisterConfig::new yields sensible defaults. +#[test] +fn tc080_config_defaults() { + let cfg = SqlitePersisterConfig::new("/tmp/some.db"); + assert!(matches!( + cfg.flush_mode, + platform_wallet_storage::FlushMode::Immediate + )); + assert_eq!(cfg.busy_timeout, std::time::Duration::from_secs(5)); + assert!(matches!( + cfg.journal_mode, + platform_wallet_storage::JournalMode::Wal + )); + assert!(matches!(cfg.synchronous, Synchronous::Normal)); + assert!(cfg.auto_backup_dir.is_some()); +} + +/// TC-081: LockPoisoned round-trips into PersistenceError::LockPoisoned. +#[test] +fn tc081_lock_poisoned_mapping() { + use platform_wallet::changeset::PersistenceError; + let err = WalletStorageError::LockPoisoned; + let mapped: PersistenceError = err.into(); + assert!(matches!(mapped, PersistenceError::LockPoisoned)); +} + +/// TC-007: IdentityKeysChangeSet stores public-only material that +/// round-trips through `identity_keys.public_key_blob`. +#[test] +fn tc007_identity_key_entry_roundtrip() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::prelude::Identifier; + use platform_wallet::changeset::{ + IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, + }; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xF7); + ensure_wallet_meta(&persister, &w); + + let identity_id = Identifier::from([0xAA; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + identity_id.as_slice().try_into().unwrap(), + ) + .unwrap(); + + let public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }); + let entry = IdentityKeyEntry { + identity_id, + key_id: 7, + public_key: public_key.clone(), + public_key_hash: [3u8; 20], + wallet_id: Some(w), + derivation_indices: Some(IdentityKeyDerivationIndices { + identity_index: 1, + key_index: 2, + }), + }; + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((identity_id, 7), entry.clone()); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p2.lock_conn_for_test(); + let blob_bytes: Vec = conn + .query_row( + "SELECT public_key_blob FROM identity_keys WHERE wallet_id = ?1 AND identity_id = ?2 AND key_id = ?3", + rusqlite::params![w.as_slice(), identity_id.as_slice(), 7i64], + |row| row.get(0), + ) + .unwrap(); + let decoded = + platform_wallet_storage::sqlite::schema::identity_keys::decode_entry(&blob_bytes).unwrap(); + assert_eq!(decoded, entry); + // The load-bearing NFR-10 check is `tests/secrets_scan.rs`, + // which greps every file under `src/sqlite/schema/` and + // `migrations/` for forbidden secret-material substrings — + // bincode wire bytes carry no field names, so any runtime + // substring scan against the blob would be a false-confidence + // smoke test. + drop(tmp); +} + +/// TC-009: PlatformAddressChangeSet round-trips through +/// `platform_addresses`. The typed columns (account_index, +/// address_index, address, balance, nonce) carry the entire +/// `PlatformAddressBalanceEntry` shape — no blob column needed for +/// this table, but we exercise the schema writer + a direct probe. +#[test] +fn tc009_platform_address_roundtrip() { + use dash_sdk::platform::address_sync::AddressFunds; + use key_wallet::PlatformP2PKHAddress; + use platform_wallet::changeset::{PlatformAddressBalanceEntry, PlatformAddressChangeSet}; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xF8); + ensure_wallet_meta(&persister, &w); + + let addr1 = PlatformP2PKHAddress::new([0x11; 20]); + let addr2 = PlatformP2PKHAddress::new([0x22; 20]); + let entries = vec![ + PlatformAddressBalanceEntry { + wallet_id: w, + account_index: 0, + address_index: 0, + address: addr1, + funds: AddressFunds { + nonce: 1, + balance: 500, + }, + }, + PlatformAddressBalanceEntry { + wallet_id: w, + account_index: 0, + address_index: 1, + address: addr2, + funds: AddressFunds { + nonce: 2, + balance: 1500, + }, + }, + ]; + persister + .store( + w, + PlatformWalletChangeSet { + platform_addresses: Some(PlatformAddressChangeSet { + addresses: entries.clone(), + sync_height: Some(99), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let rows = platform_wallet_storage::sqlite::schema::platform_addrs::list_per_wallet( + &p2.lock_conn_for_test(), + &w, + ) + .unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].address, addr1); + assert_eq!(rows[0].funds.balance, 500); + assert_eq!(rows[0].funds.nonce, 1); + assert_eq!(rows[1].address, addr2); + assert_eq!(rows[1].funds.balance, 1500); + drop(tmp); +} + +/// TC-014: AccountRegistrationEntry round-trips through +/// `account_registrations` via the bincode-serde blob. +#[test] +fn tc014_account_registration_roundtrip() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::bip32::ExtendedPubKey; + use platform_wallet::changeset::AccountRegistrationEntry; + + // Synthesise a deterministic xpub from a fixed test seed so the + // test is reproducible without external fixtures. + let xpub = ExtendedPubKey::decode(&hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC2DF1050E2E8FF49C85C2", + ).unwrap()).unwrap(); + let entry = AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + account_xpub: xpub, + }; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xFE); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + account_registrations: vec![entry.clone()], + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p2.lock_conn_for_test(); + let blob_bytes: Vec = conn + .query_row( + "SELECT account_xpub_bytes FROM account_registrations WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + let decoded: AccountRegistrationEntry = + platform_wallet_storage::sqlite::schema::blob::decode(&blob_bytes).unwrap(); + assert_eq!(decoded.account_type, entry.account_type); + assert_eq!(decoded.account_xpub, xpub); + drop(tmp); +} + +/// TC-010: AssetLockChangeSet round-trips lifecycle data — including +/// the embedded `Transaction` and optional `AssetLockProof` — through +/// the bincode-serde payload in `asset_locks.lifecycle_blob`. +#[test] +fn tc010_asset_lock_roundtrip() { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, Txid}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + + let txid = Txid::from_byte_array([0x42; 32]); + let outpoint = OutPoint { txid, vout: 1 }; + let transaction = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let entry = AssetLockEntry { + out_point: outpoint, + transaction: transaction.clone(), + account_index: 5, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 9, + amount_duffs: 12_345, + status: AssetLockStatus::Built, + proof: None, + }; + let mut locks = AssetLockChangeSet::default(); + locks.asset_locks.insert(outpoint, entry.clone()); + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xFD); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + asset_locks: Some(locks), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let bucketed = platform_wallet_storage::sqlite::schema::asset_locks::list_active( + &p2.lock_conn_for_test(), + &w, + ) + .unwrap(); + let by_outpoint = &bucketed[&5]; + let tracked = &by_outpoint[&outpoint]; + assert_eq!(tracked.amount, entry.amount_duffs); + assert_eq!(tracked.account_index, entry.account_index); + assert_eq!(tracked.identity_index, entry.identity_index); + assert_eq!(tracked.funding_type, entry.funding_type); + assert_eq!(tracked.status, entry.status); + assert_eq!(tracked.transaction.version, transaction.version); + drop(tmp); +} + +/// TC-012: DashPay profile + payment overlay round-trip through the +/// dashpay_* tables via bincode-serde blobs. +#[test] +fn tc012_dashpay_overlay_roundtrip() { + use dpp::prelude::Identifier; + use platform_wallet::wallet::identity::{DashPayProfile, PaymentEntry}; + + let (persister, tmp, path) = fresh_persister(); + let w = wid(0xFC); + ensure_wallet_meta(&persister, &w); + let identity_id = Identifier::from([0x55; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + identity_id.as_slice().try_into().unwrap(), + ) + .unwrap(); + + let profile = DashPayProfile { + display_name: Some("alice".into()), + bio: Some("hello world".into()), + avatar_url: None, + avatar_hash: None, + avatar_fingerprint: None, + public_message: Some("public".into()), + }; + let payment = PaymentEntry::new_sent(Identifier::from([0x66; 32]), 7_500, Some("lunch".into())); + + let mut profiles = std::collections::BTreeMap::new(); + profiles.insert(identity_id, Some(profile.clone())); + let mut by_tx = std::collections::BTreeMap::new(); + by_tx.insert("tx-aaaa".to_string(), payment.clone()); + let mut payments = std::collections::BTreeMap::new(); + payments.insert(identity_id, by_tx); + + persister + .store( + w, + PlatformWalletChangeSet { + dashpay_profiles: Some(profiles), + dashpay_payments_overlay: Some(payments), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p2.lock_conn_for_test(); + let profile_blob: Vec = conn + .query_row( + "SELECT profile_blob FROM dashpay_profiles WHERE wallet_id = ?1 AND identity_id = ?2", + rusqlite::params![w.as_slice(), identity_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + let decoded_profile: DashPayProfile = + platform_wallet_storage::sqlite::schema::blob::decode(&profile_blob).unwrap(); + assert_eq!(decoded_profile, profile); + + let payment_blob: Vec = conn + .query_row( + "SELECT overlay_blob FROM dashpay_payments_overlay WHERE wallet_id = ?1 AND identity_id = ?2 AND payment_id = ?3", + rusqlite::params![w.as_slice(), identity_id.as_slice(), "tx-aaaa"], + |row| row.get(0), + ) + .unwrap(); + let decoded_payment: PaymentEntry = + platform_wallet_storage::sqlite::schema::blob::decode(&payment_blob).unwrap(); + assert_eq!(decoded_payment, payment); + drop(tmp); +} + +/// TC-082 (lint): grep for `Box` in the crate's sources. +#[test] +fn tc082_no_box_dyn_error_in_src() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut offenders = Vec::new(); + visit(&root, &mut offenders); + assert!( + offenders.is_empty(), + "Box found in: {offenders:?}" + ); + + fn visit(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let p = entry.path(); + if p.is_dir() { + visit(&p, out); + } else if p.extension().is_some_and(|e| e == "rs") { + let s = std::fs::read_to_string(&p).unwrap(); + if s.contains("Box, } @@ -228,6 +238,7 @@ impl Merge for CoreChangeSet { /// call [`IdentityEntry::from_managed`] to produce a fresh scalar /// snapshot so the merge can resolve the latest state by last-write-wins. #[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityEntry { /// Identity identifier. pub id: Identifier, @@ -302,6 +313,7 @@ impl IdentityEntry { /// path — platform-wallet itself never carries or persists the key /// bytes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityKeyDerivationIndices { /// DIP-9 identity index (hardened). pub identity_index: u32, @@ -322,6 +334,7 @@ pub struct IdentityKeyDerivationIndices { /// persist it. When either is `None` the key is watch-only from /// this wallet's point of view. #[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityKeyEntry { /// Owning identity. pub identity_id: Identifier, @@ -350,6 +363,7 @@ pub struct IdentityKeyEntry { /// `{upsert, remove}` per key per mutation — the merge does not resolve /// insert-vs-tombstone for the same key. #[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityKeysChangeSet { /// Inserted or updated identity keys keyed by (identity_id, key_id). pub upserts: BTreeMap<(Identifier, KeyID), IdentityKeyEntry>, @@ -386,6 +400,7 @@ impl Merge for IdentityKeysChangeSet { /// [`ContactChangeSet`]; same mitigation: every current emitter /// produces only one of {insert, tombstone} per key per mutation. #[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityChangeSet { /// Inserted or updated identities keyed by identifier. pub identities: BTreeMap, @@ -471,6 +486,7 @@ impl Merge for IdentityChangeSet { /// /// Modelled after [`crate::wallet::identity::ContactRequest`]. #[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ContactRequestEntry { /// The contact request. pub request: ContactRequest, @@ -479,6 +495,7 @@ pub struct ContactRequestEntry { /// Key for sent contact requests: the **owner** sent a request TO the /// **recipient**. Used for `sent_requests` and `removed_sent`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct SentContactRequestKey { /// The identity owned by this wallet (the sender). pub owner_id: Identifier, @@ -490,6 +507,7 @@ pub struct SentContactRequestKey { /// FROM the **sender**. Used for `incoming_requests` and /// `removed_incoming`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ReceivedContactRequestKey { /// The identity owned by this wallet (the recipient). pub owner_id: Identifier, @@ -538,6 +556,7 @@ pub struct ReceivedContactRequestKey { /// semantics, the merge impl should resolve `sent_requests ∩ /// removed_sent` by last-seen rather than carrying both. #[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ContactChangeSet { /// Sent contact requests keyed by (owner → recipient). pub sent_requests: BTreeMap, @@ -600,15 +619,21 @@ impl Merge for ContactChangeSet { /// persisters can apply the entry without guessing which account or /// HD slot it belongs to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PlatformAddressBalanceEntry { pub wallet_id: WalletId, pub account_index: u32, pub address_index: u32, pub address: PlatformP2PKHAddress, + #[cfg_attr( + feature = "serde", + serde(with = "crate::changeset::serde_adapters::address_funds") + )] pub funds: AddressFunds, } #[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PlatformAddressChangeSet { /// Updated platform addresses produced by the last sync pass. /// A `Vec` rather than a map because the diff already deduplicates @@ -665,6 +690,7 @@ impl Merge for PlatformAddressChangeSet { /// Changes to the asset lock store. #[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct AssetLockChangeSet { /// Asset lock entries keyed by outpoint (txid + output index). /// @@ -681,6 +707,7 @@ pub struct AssetLockChangeSet { /// Contains all fields needed to fully reconstruct a /// [`TrackedAssetLock`](crate::wallet::asset_lock::tracked::TrackedAssetLock). #[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct AssetLockEntry { /// The outpoint identifying this credit output (txid + vout). pub out_point: OutPoint, @@ -689,6 +716,10 @@ pub struct AssetLockEntry { /// BIP44 account index that funded this asset lock (UTXO source). pub account_index: u32, /// Which funding account to derive the one-time key from. + #[cfg_attr( + feature = "serde", + serde(with = "crate::changeset::serde_adapters::asset_lock_funding_type") + )] pub funding_type: AssetLockFundingType, /// Identity index used during creation. pub identity_index: u32, @@ -723,6 +754,7 @@ impl Merge for AssetLockChangeSet { /// purely in the manager's in-memory cache. Persistence carries only /// the post-sync balance updates and tombstones. #[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct TokenBalanceChangeSet { /// Updated token balances keyed by `(identity_id, token_id)`. /// Last write wins on merge. @@ -763,6 +795,7 @@ impl Merge for TokenBalanceChangeSet { /// time; the parent `Option<...>` field stays `None` for every other /// flush. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct WalletMetadataEntry { /// Network the wallet is bound to. pub network: Network, @@ -787,6 +820,7 @@ pub struct WalletMetadataEntry { /// is simple `extend` and dedup is the apply-side caller's /// responsibility if it ever matters. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct AccountRegistrationEntry { /// The account variant being registered. pub account_type: AccountType, @@ -820,6 +854,7 @@ pub struct AccountRegistrationEntry { /// the upstream type. Tests that need to inspect snapshot contents /// reach into the `addresses` vec by index instead. #[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct AccountAddressPoolEntry { /// Which account this pool belongs to. pub account_type: AccountType, @@ -847,6 +882,7 @@ pub struct AccountAddressPoolEntry { /// Not `PartialEq` because [`CoreChangeSet`] isn't (its `records` carry /// `TransactionRecord`, which is `Debug + Clone` only upstream). #[derive(Debug, Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PlatformWalletChangeSet { /// Core-wallet deltas projected from upstream `WalletEvent`s: /// transaction records, UTXO add/remove, height checkpoints, IS-lock diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index bd6650431fe..364a2ca3e3b 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -16,6 +16,8 @@ pub mod core_bridge; pub mod identity_manager_start_state; pub mod merge; pub mod platform_address_sync_start_state; +#[cfg(feature = "serde")] +pub mod serde_adapters; pub mod traits; pub use changeset::{ diff --git a/packages/rs-platform-wallet/src/changeset/serde_adapters.rs b/packages/rs-platform-wallet/src/changeset/serde_adapters.rs new file mode 100644 index 00000000000..330fab55c80 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/serde_adapters.rs @@ -0,0 +1,88 @@ +//! `serde::with` adapters for upstream types that don't (yet) derive +//! their own `Serialize`/`Deserialize`. +//! +//! Compiled only when the crate's `serde` feature is on (see the +//! `#[cfg(feature = "serde")]` gate on the `pub mod` line in +//! `changeset/mod.rs`). + +use dash_sdk::platform::address_sync::AddressFunds; +use dpp::balances::credits::Credits; +use dpp::prelude::AddressNonce; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// Adapter for `AssetLockFundingType` (upstream has no serde derive). +/// +/// Encodes each variant as a stable u8 tag — same tag space the +/// hand-rolled `BlobWriter` used before the serde swap, kept for +/// forward/backward compatibility of on-disk blobs. +pub mod asset_lock_funding_type { + use super::*; + + pub fn serialize( + value: &AssetLockFundingType, + serializer: S, + ) -> Result { + let tag: u8 = match value { + AssetLockFundingType::IdentityRegistration => 0, + AssetLockFundingType::IdentityTopUp => 1, + AssetLockFundingType::IdentityTopUpNotBound => 2, + AssetLockFundingType::IdentityInvitation => 3, + AssetLockFundingType::AssetLockAddressTopUp => 4, + AssetLockFundingType::AssetLockShieldedAddressTopUp => 5, + }; + tag.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result { + let tag = u8::deserialize(deserializer)?; + Ok(match tag { + 0 => AssetLockFundingType::IdentityRegistration, + 1 => AssetLockFundingType::IdentityTopUp, + 2 => AssetLockFundingType::IdentityTopUpNotBound, + 3 => AssetLockFundingType::IdentityInvitation, + 4 => AssetLockFundingType::AssetLockAddressTopUp, + 5 => AssetLockFundingType::AssetLockShieldedAddressTopUp, + other => { + return Err(serde::de::Error::custom(format!( + "unknown AssetLockFundingType tag: {other}" + ))) + } + }) + } +} + +/// Adapter for `AddressFunds` (re-exported from `dash-sdk`; no serde +/// derive there). Encodes the two scalar fields side-by-side. +pub mod address_funds { + use super::*; + + #[derive(Serialize, Deserialize)] + struct Wire { + nonce: AddressNonce, + balance: Credits, + } + + pub fn serialize( + value: &AddressFunds, + serializer: S, + ) -> Result { + Wire { + nonce: value.nonce, + balance: value.balance, + } + .serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result { + let w = Wire::deserialize(deserializer)?; + Ok(AddressFunds { + nonce: w.nonce, + balance: w.balance, + }) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs index 7939e67d03c..6a06632d119 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -14,6 +14,7 @@ use crate::changeset::AssetLockEntry; /// Asset lock status on Core chain. Tracked until consumed, then removed. #[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum AssetLockStatus { Built, Broadcast, diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs b/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs index 7c6e28d039b..b4291e5b57a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs @@ -7,6 +7,7 @@ use dpp::prelude::{BlockHeight, CoreBlockHeight, TimestampMillis}; /// Block time information containing height, core height, and timestamp #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct BlockTime { /// Platform block height pub height: BlockHeight, diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs index 73a2c45337f..d0b1540a3ce 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs @@ -8,6 +8,7 @@ use dpp::prelude::{CoreBlockHeight, Identifier}; /// A contact request represents a one-way relationship between two identities #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ContactRequest { /// The unique id of the sender (owner of the contact request) pub sender_id: Identifier, diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs index b1be89ef227..49cfe288d5b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs @@ -10,6 +10,7 @@ use dpp::prelude::Identifier; /// /// This is formed when both identities have sent contact requests to each other. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct EstablishedContact { /// The contact's identity unique identifier pub contact_identity_id: Identifier, diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/payment.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/payment.rs index fd1044c0c20..976b64acad3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/payment.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/payment.rs @@ -19,6 +19,7 @@ use dpp::prelude::Identifier; /// Direction of a DashPay payment, from the owner's point of view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum PaymentDirection { /// The owner sent this payment to the counterparty. Sent, @@ -28,6 +29,7 @@ pub enum PaymentDirection { /// Status of a DashPay payment on Core chain. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum PaymentStatus { /// Broadcast but not yet confirmed. #[default] @@ -63,6 +65,7 @@ pub struct DashpayAddressMatch { /// Keyed by transaction id (hex string, matching evo-tool's /// `dashpay_payments.tx_id` column which is `TEXT UNIQUE NOT NULL`). #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PaymentEntry { /// The other identity in this payment. Whether they're the /// sender or receiver is encoded in `direction`. diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs index 4082f42035f..06d6d415529 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs @@ -21,6 +21,7 @@ use sha2::{Digest, Sha256}; /// User-facing DashPay profile data published via the DashPay data /// contract. This is the **output/stored** model — no raw image bytes. #[derive(Debug, Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct DashPayProfile { /// Display name (publicly visible, max 25 chars per DIP-15). pub display_name: Option, diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/key_storage.rs b/packages/rs-platform-wallet/src/wallet/identity/types/key_storage.rs index 4813dca6de5..8dee1a702b9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/key_storage.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/key_storage.rs @@ -27,6 +27,7 @@ pub enum PrivateKeyData { /// Identity lifecycle status on Platform. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum IdentityStatus { #[default] Unknown, @@ -38,6 +39,7 @@ pub enum IdentityStatus { /// DPNS username associated with an identity. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct DpnsNameInfo { pub label: String, pub acquired_at: Option, diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs index f7d83a2fff3..aec6d5b4f9d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -117,11 +117,7 @@ impl PlatformAddressWallet { .platform_payment_managed_account_at_index_mut(*account_index) { for (p2pkh, funds) in account_state.found() { - account.set_address_credit_balance( - *p2pkh, - funds.balance, - None, - ); + account.set_address_credit_balance(*p2pkh, funds.balance, None); } } }