diff --git a/.github/package-filters/js-packages-no-workflows.yml b/.github/package-filters/js-packages-no-workflows.yml index 1ecb9012439..6fe45a7e76a 100644 --- a/.github/package-filters/js-packages-no-workflows.yml +++ b/.github/package-filters/js-packages-no-workflows.yml @@ -36,7 +36,7 @@ - packages/rs-dpp/** # NOTE: do not add `!packages/rs-dpp/**/tests.rs` style negation # patterns here. The dispatcher in `tests.yml` runs these filters - # via `dorny/paths-filter@v3` with the default + # via `dorny/paths-filter@v4` with the default # `predicate-quantifier: some`, under which each pattern (including # `!`-prefixed ones) is OR'd independently. A `!` pattern then # "matches" every file that doesn't match the negated path — i.e. @@ -103,7 +103,7 @@ dashmate: - packages/rs-dash-platform-macros/** - packages/dapi-grpc/** # NOTE: do not add `!path` negation patterns here — see the long - # explanation on `@dashevo/wasm-dpp` above. Same `dorny/paths-filter@v3` + # explanation on `@dashevo/wasm-dpp` above. Same `dorny/paths-filter@v4` # + `predicate-quantifier: some` interaction trips this filter on # unrelated changes and cascades into every consumer (`*wasm-sdk`). diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f04c71c92ae..af787939bb2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -52,6 +52,7 @@ jobs: release wasm-sdk platform-wallet + wallet-storage swift-example-app kotlin-sdk kotlin-example-app diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 27e9d99906d..b62f70f986a 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -166,6 +166,7 @@ jobs: --package platform-value \ --package rs-dapi \ --package platform-wallet \ + --package platform-wallet-storage \ --package rs-sdk-ffi \ --package platform-wallet-ffi \ --package rs-dapi-client \ @@ -339,6 +340,7 @@ jobs: --package platform-value \ --package rs-dapi \ --package platform-wallet \ + --package platform-wallet-storage \ --package rs-sdk-ffi \ --package platform-wallet-ffi \ --package rs-dapi-client \ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2afaacd4ef4..b5971dcfbd8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,25 +55,25 @@ jobs: with: fetch-depth: 0 - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@v4 id: filter-js if: ${{ github.event_name != 'workflow_dispatch' }} with: filters: .github/package-filters/js-packages-no-workflows.yml - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@v4 id: filter-js-direct if: ${{ github.event_name != 'workflow_dispatch' }} with: filters: .github/package-filters/js-packages-direct.yml - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@v4 id: filter-rs if: ${{ github.event_name != 'workflow_dispatch' }} with: filters: .github/package-filters/rs-packages-no-workflows.yml - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@v4 id: filter-rs-workflows if: ${{ github.event_name != 'workflow_dispatch' }} with: @@ -116,7 +116,7 @@ jobs: - name: Check for Swift SDK changes id: filter-swift-sdk if: ${{ github.event_name != 'workflow_dispatch' }} - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@v4 with: filters: | swift-sdk-changed: diff --git a/Cargo.lock b/Cargo.lock index f0089412bd5..fe6dd2e3186 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -158,6 +169,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -170,6 +193,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "assert_matches" version = "1.5.0" @@ -552,7 +590,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 +608,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" @@ -607,6 +660,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "blake2b_simd" version = "1.0.4" @@ -778,6 +840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -1409,6 +1472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array 0.14.7", + "rand_core 0.6.4", "typenum", ] @@ -1767,6 +1831,46 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2", + "zeroize", +] + +[[package]] +name = "dbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8f54da401bb5eb2a4d873ac4b359f4a599df2ca8634bb5b8c045e5ee78757" +dependencies = [ + "dbus-secret-service", + "keyring-core", +] + [[package]] name = "delegate" version = "0.13.5" @@ -1858,6 +1962,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" @@ -2306,7 +2416,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", ] @@ -2317,6 +2427,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -2353,6 +2474,16 @@ dependencies = [ "flate2", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2376,6 +2507,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" @@ -2682,7 +2822,7 @@ dependencies = [ "memuse", "rand 0.8.6", "rand_core 0.6.4", - "rand_xorshift", + "rand_xorshift 0.3.0", "subtle", ] @@ -3840,6 +3980,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "keyword-search-contract" version = "3.1.0-dev.8" @@ -3884,6 +4033,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -4004,6 +4163,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "masternode-reward-shares-contract" version = "3.1.0-dev.8" @@ -4298,6 +4466,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" @@ -4517,6 +4691,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.116" @@ -4525,6 +4708,7 @@ checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -4611,6 +4795,17 @@ dependencies = [ "regex", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pasta_curves" version = "0.5.1" @@ -4883,6 +5078,51 @@ dependencies = [ "zeroize", ] +[[package]] +name = "platform-wallet-storage" +version = "3.1.0-dev.8" +dependencies = [ + "apple-native-keyring-store", + "argon2", + "assert_cmd", + "bincode", + "chacha20poly1305", + "chrono", + "clap", + "dash-sdk", + "dashcore", + "dbus-secret-service-keyring-store", + "dpp", + "fd-lock", + "filetime", + "getrandom 0.2.17", + "hex", + "humantime", + "key-wallet", + "keyring-core", + "libc", + "platform-wallet", + "platform-wallet-storage", + "predicates", + "proptest", + "refinery", + "region", + "rusqlite", + "serde", + "serde_json", + "serial_test", + "sha2", + "static_assertions", + "subtle", + "tempfile", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", + "tracing-test", + "windows-native-keyring-store", + "zeroize", +] + [[package]] name = "plotters" version = "0.3.7" @@ -4981,7 +5221,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]] @@ -5085,6 +5329,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" @@ -5251,6 +5514,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick_cache" version = "0.6.22" @@ -5431,6 +5700,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" @@ -5525,6 +5803,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" @@ -5554,6 +5873,18 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "mach2", + "windows-sys 0.52.0", +] + [[package]] name = "rend" version = "0.4.2" @@ -6141,6 +6472,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" @@ -7691,6 +8034,27 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-wasm" version = "0.2.1" @@ -7764,6 +8128,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 +8336,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" @@ -8406,6 +8785,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5fd986f648459dd29aa252ed3a5ad11a60c0b1251bf81625fb03a86c69d274e" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-registry" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index 786b2d40b36..ac3bcf08f4b 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 ff14ceaf1d3..f3f4f2a05f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -403,6 +403,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 \ @@ -511,6 +512,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 \ @@ -911,6 +913,7 @@ COPY --parents \ packages/rs-sdk-ffi \ packages/rs-unified-sdk-ffi \ packages/rs-platform-wallet \ + packages/rs-platform-wallet-storage \ packages/check-features \ packages/dash-platform-balance-checker \ packages/wasm-sdk \ diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 5a860e3bed9..c0464642033 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1233,11 +1233,9 @@ impl PlatformWalletPersistence for FFIPersister { } if !round_success { - return Err( - "one or more persistence callbacks failed; changeset was rolled back" - .to_string() - .into(), - ); + return Err(PersistenceError::backend( + "one or more persistence callbacks failed; changeset was rolled back", + )); } // Merge into pending changesets. @@ -1251,9 +1249,10 @@ impl PlatformWalletPersistence for FFIPersister { if let Some(cb) = self.callbacks.on_store_fn { let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; if result != 0 { - return Err( - format!("Persistence store callback returned error code {}", result).into(), - ); + return Err(PersistenceError::backend(format!( + "Persistence store callback returned error code {}", + result + ))); } } @@ -1265,9 +1264,10 @@ impl PlatformWalletPersistence for FFIPersister { if let Some(cb) = self.callbacks.on_flush_fn { let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; if result != 0 { - return Err( - format!("Persistence flush callback returned error code {}", result).into(), - ); + return Err(PersistenceError::backend(format!( + "Persistence flush callback returned error code {}", + result + ))); } } @@ -1293,7 +1293,10 @@ impl PlatformWalletPersistence for FFIPersister { let mut count: usize = 0; let rc = unsafe { load_cb(self.callbacks.context, &mut entries_ptr, &mut count) }; if rc != 0 { - return Err(format!("on_load_wallet_list_fn returned error code {}", rc).into()); + return Err(PersistenceError::backend(format!( + "on_load_wallet_list_fn returned error code {}", + rc + ))); } let _guard = LoadGuard { context: self.callbacks.context, @@ -1343,12 +1346,10 @@ impl PlatformWalletPersistence for FFIPersister { if self.callbacks.on_load_shielded_notes_fn.is_some() != self.callbacks.on_load_shielded_notes_free_fn.is_some() { - return Err( + return Err(PersistenceError::backend( "on_load_shielded_notes_fn and on_load_shielded_notes_free_fn must be \ - provided together" - .to_string() - .into(), - ); + provided together", + )); } if self.callbacks.on_load_shielded_sync_states_fn.is_some() != self @@ -1356,12 +1357,10 @@ impl PlatformWalletPersistence for FFIPersister { .on_load_shielded_sync_states_free_fn .is_some() { - return Err( + return Err(PersistenceError::backend( "on_load_shielded_sync_states_fn and on_load_shielded_sync_states_free_fn \ - must be provided together" - .to_string() - .into(), - ); + must be provided together", + )); } // 1) notes @@ -1371,9 +1370,10 @@ impl PlatformWalletPersistence for FFIPersister { let rc = unsafe { load_notes(self.callbacks.context, &mut notes_ptr, &mut notes_count) }; if rc != 0 { - return Err( - format!("on_load_shielded_notes_fn returned error code {}", rc).into(), - ); + return Err(PersistenceError::backend(format!( + "on_load_shielded_notes_fn returned error code {}", + rc + ))); } struct NotesGuard { context: *mut c_void, @@ -1436,11 +1436,10 @@ impl PlatformWalletPersistence for FFIPersister { load_states(self.callbacks.context, &mut states_ptr, &mut states_count) }; if rc != 0 { - return Err(format!( + return Err(PersistenceError::backend(format!( "on_load_shielded_sync_states_fn returned error code {}", rc - ) - .into()); + ))); } struct StatesGuard { context: *mut c_void, @@ -2267,14 +2266,17 @@ fn build_wallet_start_state( let xpub_bytes = unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) }; let (account_xpub, _): (ExtendedPubKey, usize) = - bincode::decode_from_slice(xpub_bytes, config::standard()) - .map_err(|e| format!("failed to decode account xpub: {}", e))?; + bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { + PersistenceError::backend(format!("failed to decode account xpub: {}", e)) + })?; let account = Account::from_xpub(Some(entry.wallet_id), account_type, account_xpub, network) - .map_err(|e| format!("Account::from_xpub failed: {:?}", e))?; - accounts - .insert(account) - .map_err(|e| format!("AccountCollection::insert failed: {}", e))?; + .map_err(|e| { + PersistenceError::backend(format!("Account::from_xpub failed: {:?}", e)) + })?; + accounts.insert(account).map_err(|e| { + PersistenceError::backend(format!("AccountCollection::insert failed: {}", e)) + })?; } // External-signable wallet — the mnemonic / seed lives in the @@ -2915,7 +2917,7 @@ fn build_unused_asset_locks( for spec in specs { // Decode the outpoint: 32-byte raw txid + 4-byte LE vout. let txid = dashcore::Txid::from_slice(&spec.out_point[..32]).map_err(|e| { - PersistenceError::from(format!( + PersistenceError::backend(format!( "tracked asset lock: invalid txid in outpoint: {}", e )) @@ -2928,8 +2930,8 @@ fn build_unused_asset_locks( // Decode the consensus-encoded transaction. if spec.transaction_bytes.is_null() || spec.transaction_bytes_len == 0 { - return Err(PersistenceError::from( - "tracked asset lock: empty transaction bytes".to_string(), + return Err(PersistenceError::backend( + "tracked asset lock: empty transaction bytes", )); } // SAFETY: Swift guarantees the buffer is valid for the @@ -2939,7 +2941,7 @@ fn build_unused_asset_locks( unsafe { slice::from_raw_parts(spec.transaction_bytes, spec.transaction_bytes_len) }; let transaction: dashcore::Transaction = dashcore::consensus::deserialize(tx_bytes) .map_err(|e| { - PersistenceError::from(format!( + PersistenceError::backend(format!( "tracked asset lock: failed to decode transaction: {}", e )) @@ -2959,7 +2961,10 @@ fn build_unused_asset_locks( config::standard(), ) .map_err(|e| { - PersistenceError::from(format!("tracked asset lock: failed to decode proof: {}", e)) + PersistenceError::backend(format!( + "tracked asset lock: failed to decode proof: {}", + e + )) })?; Some(proof) }; @@ -3010,7 +3015,7 @@ fn funding_type_from_u8( 4 => AssetLockFundingType::AssetLockAddressTopUp, 5 => AssetLockFundingType::AssetLockShieldedAddressTopUp, other => { - return Err(PersistenceError::from(format!( + return Err(PersistenceError::backend(format!( "tracked asset lock: unknown funding_type discriminant {}", other ))) @@ -3027,7 +3032,7 @@ fn status_from_u8(b: u8) -> Result AssetLockStatus::ChainLocked, 4 => AssetLockStatus::Consumed, other => { - return Err(PersistenceError::from(format!( + return Err(PersistenceError::backend(format!( "tracked asset lock: unknown status discriminant {}", other ))) @@ -3226,7 +3231,7 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result Result { let standard_tag = StandardAccountTypeTagFFI::try_from_u8(spec.standard_tag) .ok_or_else(|| { - PersistenceError::Backend(format!( + PersistenceError::backend(format!( "AccountSpecFFI(Standard) carries unknown standard_tag byte {}", spec.standard_tag )) @@ -3290,7 +3295,7 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result { - return Err(PersistenceError::Backend(format!( + return Err(PersistenceError::backend(format!( "AccountTypeTagFFI {:?} is no longer mappable to a key-wallet AccountType after the upstream event-bus refactor (TODO(events))", type_tag ))); @@ -3393,10 +3398,10 @@ fn restore_unresolved_asset_lock_tx_records( let context = match rec.context_raw { 2 => { let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| { - format!( + PersistenceError::backend(format!( "load: malformed block_hash on unresolved asset-lock tx record: {}", e - ) + )) })?; TransactionContext::InBlock(BlockInfo::new( rec.block_height, @@ -3406,10 +3411,10 @@ fn restore_unresolved_asset_lock_tx_records( } 3 => { let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| { - format!( + PersistenceError::backend(format!( "load: malformed block_hash on unresolved asset-lock tx record: {}", e - ) + )) })?; TransactionContext::InChainLockedBlock(BlockInfo::new( rec.block_height, diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml new file mode 100644 index 00000000000..c3696c231a9 --- /dev/null +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -0,0 +1,211 @@ +[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 and keyring_core secret backends (encrypted-file + OS keyring)." + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "platform-wallet-storage" +path = "src/bin/platform-wallet-storage.rs" +required-features = ["cli"] + +[dependencies] +# Truly cross-cutting deps (always on regardless of features). +thiserror = "1" +tracing = "0.1" +hex = "0.4" + +# SQLite-backed persister deps (gated by the `sqlite` feature). +# `platform-wallet` types are reachable through the `sqlite` submodule +# only; without the feature the bare crate ships no items that mention +# them, so the wallet/serde graph stays out of the build (CODE-020). +# `dpp` types reach the persister via `IdentityPublicKey` (identity_keys +# writer), `AssetLockProof` (asset_locks writer) and `Identifier` +# (dashpay writer). `dash-sdk` is here for the `AddressFunds` re-export +# in `schema/platform_addrs.rs`. Feature set mirrors sibling +# `rs-platform-wallet` so the resolver picks identical hashes. +platform-wallet = { path = "../rs-platform-wallet", features = [ + "serde", +], optional = true } +serde = { version = "1", features = ["derive"], optional = true } +key-wallet = { workspace = true, optional = true } +dashcore = { workspace = true, optional = true } +dpp = { path = "../rs-dpp", optional = true } +dash-sdk = { path = "../rs-sdk", default-features = false, features = [ + "dashpay-contract", + "dpns-contract", +], optional = true } +rusqlite = { version = "0.38", features = [ + "bundled", + "backup", + "blob", + "hooks", + "trace", +], optional = true } +refinery = { version = "0.9", default-features = false, features = [ + "rusqlite", +], optional = true } +# bincode 2 is required directly: we encode `dpp::IdentityPublicKey` +# (which derives bincode 2 `Encode`/`Decode`) and decode +# `dpp::AssetLockProof` from the asset-lock blob column. +bincode = { version = "2", optional = true } +tempfile = { version = "3", optional = true } +chrono = { version = "0.4", default-features = false, features = [ + "clock", +], optional = true } +sha2 = { version = "0.10", optional = true } + +# Secret-storage deps (gated by the `secrets` feature). RustSec-clean +# pins (Smythe §7); `aes-gcm` is deliberately omitted. `keyring`'s +# library is `keyring-core` + per-platform store crates (the `keyring` +# crate itself is sample/CLI). Verified to build under MSRV 1.92. +argon2 = { version = "=0.5.3", optional = true } +chacha20poly1305 = { version = "=0.10.1", optional = true } +zeroize = { version = "=1.8.2", features = ["derive"], optional = true } +subtle = { version = "=2.6.1", optional = true } +getrandom = { version = "0.2", optional = true } +region = { version = "=3.0.2", optional = true } +keyring-core = { version = "=1.0.0", optional = true } +# Cross-process advisory file lock for the vault RMW (CMT-001). +# `fd-lock` 4.x is pure-rustix and replaces the `fs2`/`fs4` family that +# was removed from the sqlite arm in #3743 (CODE-005/007/010/015) — those +# tests grep for `fs2`/`fs4` literals in this crate's source/manifest and +# would re-trigger on the older crates. `fd-lock` has no such collision. +fd-lock = { version = "4.0.4", optional = true } + +# 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 } + +# Per-platform OS-keyring credential stores. `keyring-core 1.0.0` is +# the API; these crates provide the platform backends (the `keyring` +# 4.x crate is the sample CLI and is intentionally not depended on). +# Gated by `secrets` via `dep:`. Target-specific tables MUST follow all +# `[dependencies]` entries. +[target.'cfg(unix)'.dependencies] +# `O_NOFOLLOW` open flag for vault read TOCTOU defence (CMT-004). +libc = { version = "0.2", optional = true } + +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +dbus-secret-service-keyring-store = { version = "=1.0.0", features = [ + "crypto-rust", + "vendored", +], optional = true } + +[target.'cfg(target_os = "macos")'.dependencies] +# macOS crate requires one of `keychain` / `protected` features or it +# emits a compile_error! (latent — only fires on macOS targets; Linux CI +# never tripped it). `keychain` = standard Keychain; `protected` is the +# user-presence-gated variant. We want the standard one for the v1 +# SecretStore SPI; the protected variant can be opt-in later. +apple-native-keyring-store = { version = "=1.0.0", features = ["keychain"], optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-native-keyring-store = { version = "=1.0.0", optional = true } + +[dev-dependencies] +proptest = "1" +assert_cmd = "2" +predicates = "3" +static_assertions = "1" +filetime = "0.2" +tracing-test = { version = "0.2", features = ["no-env-filter"] } +serial_test = "3" +# `default-features = false` so the off-state CI invocation +# (`--no-default-features --features sqlite,cli`) actually exercises a +# build with `secrets`/`kv` disabled — otherwise the dev-dep view would +# silently re-enable the default feature set for every integration test. +# Test surface is opted into explicitly: `secrets` and `kv` are listed +# so the plain `cargo test -p platform-wallet-storage` invocation runs +# both feature paths (the `kv`-gated `sqlite_object_metadata.rs` +# integration test and the `secrets`-gated unit tests). +platform-wallet-storage = { path = ".", default-features = false, features = ["sqlite", "cli", "secrets", "kv", "__test-helpers"] } +tempfile = "3" +# `sqlite_hardening_3625.rs`, `sqlite_persist_roundtrip.rs`, and +# `sqlite_load_reconstruction.rs` import `dash_sdk::platform::address_sync::AddressFunds`. +# Mocks feature lets the consumer↔persister boundary tests stand up a +# real SDK without network. (`round_trip_consumer.rs` was extracted into +# the consumer-hardening PR; tokio is no longer needed here.) +dash-sdk = { path = "../rs-sdk", default-features = false, features = [ + "dashpay-contract", + "dpns-contract", + "wallet", + "mocks", +] } + +[features] +default = ["sqlite", "cli", "secrets", "kv"] +# SQLite-backed persister (`platform_wallet_storage::sqlite`). +sqlite = [ + "dep:platform-wallet", + "dep:serde", + "dep:key-wallet", + "dep:dashcore", + "dep:dpp", + "dep:dash-sdk", + "dep:rusqlite", + "dep:refinery", + "dep:bincode", + "dep:tempfile", + "dep:chrono", + "dep:sha2", +] +# Maintenance CLI binary. Requires `sqlite` because the only subcommands +# in scope today operate on the SQLite persister. +cli = [ + "sqlite", + "dep:clap", + "dep:humantime", + "dep:serde_json", + "dep:tracing-subscriber", +] +# `secrets` submodule (`platform_wallet_storage::secrets`): zeroizing +# secret wrappers + EncryptedFile backend + OS-keyring construction +# helper, all built on the upstream `keyring_core::api` SPI. Default-on +# so `Cargo.lock` unconditionally pins the RustSec-clean crypto stack +# (SEC-REQ-4.7). Disable explicitly via `--no-default-features` to +# build the storage crate without the crypto graph. +secrets = [ + "dep:argon2", + "dep:chacha20poly1305", + # CMT-021: secrets uses serde directly (vault format + crypto + # envelope derive `Serialize`/`Deserialize`); declare the dep here + # so `--no-default-features --features secrets` builds without + # leaning on the `sqlite` feature also having `dep:serde`. + "dep:serde", + "dep:serde_json", + "dep:tempfile", + "dep:zeroize", + "dep:subtle", + "dep:getrandom", + "dep:region", + "dep:keyring-core", + "dep:fd-lock", + "dep:libc", + "dep:dbus-secret-service-keyring-store", + "dep:apple-native-keyring-store", + "dep:windows-native-keyring-store", +] +# Per-object-type key/value metadata API +# (`platform_wallet_storage::{KvStore, KvError, ObjectId}`) plus the +# SQLite-backed impl. Requires `sqlite` because the only shipped backend +# is on `SqlitePersister`. The six `meta_*` tables are always created by +# V001 so DB files stay interoperable across feature combos; this gate +# only controls the Rust API surface. +kv = ["sqlite"] +# Exposes `lock_conn_for_test` / `config_for_test` accessors on +# `SqlitePersister` so this crate's own integration tests can probe +# the write connection. The double-underscore prefix follows Cargo's +# convention for "MUST NOT enable from downstream" features +# (https://doc.rust-lang.org/cargo/reference/features.html#feature-resolver-version-2). +__test-helpers = ["sqlite"] diff --git a/packages/rs-platform-wallet-storage/README.md b/packages/rs-platform-wallet-storage/README.md new file mode 100644 index 00000000000..aeae34ecc47 --- /dev/null +++ b/packages/rs-platform-wallet-storage/README.md @@ -0,0 +1,170 @@ +# platform-wallet-storage + +Storage backends for the +[`platform-wallet`](../rs-platform-wallet) crate. This crate ships a +SQLite-backed implementation of `PlatformWalletPersistence` under +[`sqlite`](src/sqlite/), a maintenance CLI, and the +[`secrets`](src/secrets/) submodule — a `keyring_core` SPI +implementation pairing the in-house `EncryptedFileStore` +(Argon2id + XChaCha20-Poly1305 on-disk vault) with the OS keyring +backends. All three are on by default; see [`SECRETS.md`](./SECRETS.md) +for the secret-storage threat model and design. + +## At a glance + +- One `.db` file holds many wallets — every per-wallet row carries a + `wallet_id BLOB` primary-key component. +- Schema migrations are append-only Rust files under `migrations/`, + applied via [`refinery`](https://github.com/rust-db/refinery) on every + `open`. +- Online backup uses `rusqlite::backup::Backup::run_to_completion` — + safe under a concurrent writer. +- **No private-key material.** See [`SECRETS.md`](./SECRETS.md). +- `Send + Sync`; usable behind `Arc`. +- Writers use `prepare_cached` so each INSERT/UPDATE is parsed once + per `Connection` lifetime; subsequent flushes hit the cache. + +## Flush semantics + +`flush()` and `Immediate`-mode `store()` succeed-or-restore: on a +transient SQLite failure (`SQLITE_BUSY` / `SQLITE_LOCKED`) the +buffered changeset is merged back into the per-wallet buffer (LWW +with anything `store()`-d during the failed transaction) and the +call returns a `PersistenceError::Backend { kind: Transient, source }` +whose source carries the marker `flush failed transiently`. +**Retry the call** — do not discard state. Fatal failures (integrity +check, encode error, mutex poison, …) return `kind: Fatal` (or +`kind: Constraint` for SQL constraint violations) and drop the buffer. + +The full classification lives on +[`WalletStorageError::is_transient`](src/sqlite/error.rs) and the +companion [`WalletStorageError::persistence_kind`](src/sqlite/error.rs) +that selects the trait-side kind. The `source` field is a +`Box` over the original `WalletStorageError` +— operators can walk `Error::source()` for the full typed chain; +the outer `Display` carries the variant marker + hex wallet id so +production-log greps still work. + +## load() reconstruction + +`SqlitePersister::load()` returns the base `ClientStartState` +(plain struct, two slots — no `#[non_exhaustive]`): + +| Slot | Reader | Status | +|---|---|---| +| `platform_addresses` | `schema::platform_addrs::load_all` (a `wallet_meta::list_ids` → `load_state` loop) | populated | +| `wallets` | — | empty pending upstream `Wallet::from_persisted` | + +The `identities` / `contacts` / `asset_locks` per-area readers exist +as hardened dormant helpers (`schema::::load_state`) but are not +wired into `load()` — `ClientStartState` carries no slot for them. + +Loading is **fail-hard**: any row that fails to decode, or a stored +`wallet_id` that is not exactly 32 bytes, aborts the whole call with a +typed [`WalletStorageError`](src/sqlite/error.rs) +(`BincodeDecode` / `BlobDecode` / `InvalidWalletIdLength`). There is no +corruption tolerance, no per-row skip, and no partial `Ok` — a corrupt +database surfaces as an error rather than silently losing rows. + +The summary `tracing::info!` carries `wallets_seen`, +`addresses_loaded`, `wallets_rehydrated`, and +`wallets_pending_rehydration` (the count of wallets that *would* be +rehydrated once upstream provides `Wallet::from_persisted`). + +## Library usage + +```rust,no_run +use std::sync::Arc; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +let config = SqlitePersisterConfig::new("/tmp/wallets.db"); +let persister: Arc = + Arc::new(SqlitePersister::open(config)?); +# Ok::<_, platform_wallet_storage::WalletStorageError>(()) +``` + +The same types are also reachable via their canonical submodule path — +`platform_wallet_storage::sqlite::SqlitePersister` — for callers that +want to be explicit about the backend. + +`SqlitePersisterConfig::new(path)` produces sensible defaults: +`Immediate` flush, 5 s busy timeout, WAL journal, `NORMAL` +synchronous, and an auto-backup dir at `/backups/auto/`. + +## CLI + +```text +platform-wallet-storage --db migrate [--no-auto-backup] +platform-wallet-storage --db backup --out +platform-wallet-storage --db restore --from --yes +platform-wallet-storage prune --in [--keep-last N] [--max-age 30d] +platform-wallet-storage --db inspect [--wallet-id ] [--format text|tsv|json] +``` + +Destructive subcommands (`restore`) REQUIRE `--yes` — invoking them +without it exits 2 with a usage error. `--no-auto-backup` opts out of +the pre-restore (or pre-migration) auto-backup; it is the only +supported way to disable auto-backup. + +Wallet removal is a library-only API +([`SqlitePersister::delete_wallet`] / `delete_wallet_skip_backup`); +no CLI subcommand exposes it. + +Logging: `-v` / `-vv` / `-vvv` enable `info` / `debug` / `trace` +respectively on stderr; `-q` suppresses non-error output. + +Exit codes: `0` success, `1` runtime error, `2` usage error, `3` +validation failure (e.g. corrupt backup source). + +## Operational notes + +**Restore exclusion.** `restore` opens a short-lived writer connection +on the destination DB and holds a SQLite-native `BEGIN EXCLUSIVE` +transaction across the entire restore body. This interlocks with every +other SQLite peer — sibling `SqlitePersister` handles, bare +`rusqlite::Connection` instances, the CLI — so concurrent writes back +off via SQLite's `busy_timeout` instead of racing the atomic swap. If a +peer holds the destination busy for longer than the timeout, `restore` +returns `WalletStorageError::RestoreDestinationLocked`. The lock conn is +released BEFORE the rename so SQLite's file handle on the old inode goes +away before the new DB takes its place. + +**Manual-mode drop diagnostic.** `SqlitePersister` configured with +[`FlushMode::Manual`] emits a `tracing::error!` on drop if the buffer +still holds uncommitted writes (with `dirty_wallets` and `total_fields` +fields). The crate does NOT auto-flush from `Drop` — call +[`SqlitePersister::commit_writes`] (or per-wallet `flush`) before drop +to make Manual-mode writes durable. + +## Cargo features + +| Feature | Default | What it brings | +|---|---|---| +| `sqlite` | yes | SQLite persister (`platform_wallet_storage::sqlite`) and all of its native deps (`rusqlite`, `refinery`, `dpp`, `dash-sdk`, `key-wallet`, etc.) | +| `cli` | yes | Maintenance binary `platform-wallet-storage`. Implies `sqlite`. | +| `secrets` | yes | `platform_wallet_storage::secrets` submodule — zeroizing secret wrappers (`SecretBytes`, `SecretString`), the `EncryptedFileStore` Argon2id + XChaCha20-Poly1305 vault backend, and the `default_credential_store()` OS-keyring constructor. Implements the upstream `keyring_core::api::{CredentialApi, CredentialStoreApi}` SPI. | +| `__test-helpers` | no | Crate-private `lock_conn_for_test` / `config_for_test` accessors. The double-underscore prefix follows Cargo's "do not enable from downstream" convention; the methods are also `#[doc(hidden)]`. | + +`cargo build -p platform-wallet-storage --no-default-features` builds a +minimal core with neither the SQLite backend, the CLI, nor the secrets +submodule. `--no-default-features --features sqlite,cli` is the +"persister-only" build mode (no crypto dependencies). + +## Schema + +See [`migrations/V001__initial.rs`](./migrations/V001__initial.rs) for +the canonical schema. It is hand-written `CREATE TABLE … PRIMARY KEY … +FOREIGN KEY …` SQL with native `ON DELETE CASCADE` constraints; INSERT, +DELETE-cascade, and UPDATE re-parenting are all enforced by SQLite +itself. Wallet-scoped tables FK directly to `wallet_metadata`; +identity-owned tables (`identity_keys`, `token_balances`, +`dashpay_profiles`, `dashpay_payments_overlay`) are keyed by +`identity_id` only and cascade through `identities` (whose `wallet_id` +is nullable to support identity-only flows). Foreign-key enforcement is +enabled and read-back-asserted on every connection open via the +`open_conn` choke-point — if the linked SQLite cannot honor +`PRAGMA foreign_keys`, open fails hard. The single remaining trigger +clears `core_utxos.spent_in_txid` to NULL on transaction delete (a +native composite `SET NULL` would null the NOT-NULL `wallet_id` column +too). diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md new file mode 100644 index 00000000000..be699b7c137 --- /dev/null +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -0,0 +1,621 @@ +# SQLite schema — `platform-wallet-storage` + +The persister stores **public** wallet-state material (UTXOs, transactions, account registrations, address pools, identities, identity public keys, contacts, asset locks, token balances, DashPay overlays, and platform-address sync snapshots) in a SQLite database managed by [refinery](https://crates.io/crates/refinery) migrations. **No secrets are stored here** — see [SECRETS.md](./SECRETS.md) for the secret-bearing backends. + +Schema evolution is version-gated by refinery. Every read-write connection turns on `PRAGMA foreign_keys = ON` at open time (`src/sqlite/conn.rs`), so every `ON DELETE CASCADE` clause is active. The `meta_*` soft-cascade `AFTER DELETE` triggers fire even for parent rows removed by an FK cascade (SQLite does this natively), so deleting a wallet transitively cleans all its metadata. + +The 23 tables are split into five domain diagrams below. `WALLET_METADATA` is the root anchor and appears in each diagram. For full column listings see the [Tables](#tables) section. + +## Diagram 1 — Core / L1 (Bitcoin/Dash layer) + +Account registrations, address-pool snapshots, transactions, UTXOs, instant locks, derived addresses, and SPV sync state. + +```mermaid +erDiagram + WALLET_METADATA ||--o{ ACCOUNT_REGISTRATIONS : "registers" + WALLET_METADATA ||--o{ ACCOUNT_ADDRESS_POOLS : "snapshots" + WALLET_METADATA ||--o{ CORE_TRANSACTIONS : "records" + WALLET_METADATA ||--o{ CORE_UTXOS : "owns" + WALLET_METADATA ||--o{ CORE_INSTANT_LOCKS : "holds" + WALLET_METADATA ||--o{ CORE_DERIVED_ADDRESSES : "derives" + WALLET_METADATA ||--o| CORE_SYNC_STATE : "tracks" + CORE_TRANSACTIONS ||--o{ CORE_UTXOS : "spends" + + WALLET_METADATA { + BLOB wallet_id PK "32-byte WalletId" + TEXT network "mainnet | testnet | devnet | regtest" + INTEGER birth_height "SPV scan start height" + } + + ACCOUNT_REGISTRATIONS { + BLOB wallet_id PK + TEXT account_type PK "standard | coinjoin | identity_registration | ..." + INTEGER account_index PK + BLOB account_xpub_bytes "bincode-encoded AccountRegistrationEntry" + } + + ACCOUNT_ADDRESS_POOLS { + BLOB wallet_id PK + TEXT account_type PK + INTEGER account_index PK + TEXT pool_type PK "external | internal | absent | absent_hardened" + BLOB snapshot_blob "bincode-encoded AccountAddressPoolEntry" + } + + CORE_TRANSACTIONS { + BLOB wallet_id PK + BLOB txid PK "32-byte Txid" + INTEGER height "NULL if unconfirmed" + BLOB block_hash "NULL if unconfirmed" + INTEGER block_time "NULL if unconfirmed" + INTEGER finalized "0 | 1" + BLOB record_blob "bincode-encoded TransactionRecord" + } + + CORE_UTXOS { + BLOB wallet_id PK + BLOB outpoint PK "bincode-encoded OutPoint" + INTEGER value "satoshis" + BLOB script "scriptPubKey bytes" + INTEGER height "NULL if unconfirmed" + INTEGER account_index + INTEGER spent "0 | 1" + BLOB spent_in_txid "NULL until spend; cleared by trigger on tx delete" + } + + CORE_INSTANT_LOCKS { + BLOB wallet_id PK + BLOB txid PK + BLOB islock_blob "bincode-encoded InstantLock" + } + + CORE_DERIVED_ADDRESSES { + BLOB wallet_id PK + TEXT account_type PK + TEXT address PK "bech32 / Base58 address string" + INTEGER account_index + TEXT derivation_path "pool_type/derivation_index" + INTEGER used "0 | 1" + } + + CORE_SYNC_STATE { + BLOB wallet_id PK "one row per wallet" + INTEGER last_processed_height "NULL until first block processed" + INTEGER synced_height "NULL until first sync" + } +``` + +> Note: the `CORE_TRANSACTIONS → CORE_UTXOS` edge shown above is enforced by the +> `setnull_core_utxos_on_tx_delete` SQLite trigger, not a declared `FOREIGN KEY`. +> A native `ON DELETE SET NULL` composite FK would also null the NOT NULL `wallet_id` +> column — the trigger nulls only `spent_in_txid`, preserving the intended semantics. + +## Diagram 2 — Identities + DashPay (Platform L2 identity tree) + +Platform identities, their public keys, token balances, and DashPay profiles/payments. Identity-owned tables have no direct `wallet_id` column; cascade flows `wallet_metadata → identities → child`. + +```mermaid +erDiagram + WALLET_METADATA ||--o{ IDENTITIES : "parents" + IDENTITIES ||--o{ IDENTITY_KEYS : "has" + IDENTITIES ||--o{ TOKEN_BALANCES : "holds" + IDENTITIES ||--o| DASHPAY_PROFILES : "has" + IDENTITIES ||--o{ DASHPAY_PAYMENTS_OVERLAY : "overlays" + + WALLET_METADATA { + BLOB wallet_id PK "32-byte WalletId" + TEXT network + INTEGER birth_height + } + + IDENTITIES { + BLOB identity_id PK "32-byte Platform Identifier" + BLOB wallet_id FK "NULL = orphan identity (no parent wallet yet)" + INTEGER wallet_index "BIP-32 index; NULL for out-of-wallet identities" + BLOB entry_blob "bincode-encoded IdentityEntry" + INTEGER tombstoned "0 | 1 (logical delete)" + } + + IDENTITY_KEYS { + BLOB identity_id PK + INTEGER key_id PK "KeyID" + BLOB public_key_blob "bincode-encoded IdentityKeyWire (public material only)" + BLOB public_key_hash "20-byte HASH160 of the key" + } + + TOKEN_BALANCES { + BLOB identity_id PK + BLOB token_id PK "32-byte token contract Identifier" + INTEGER balance + INTEGER updated_at "Unix timestamp" + } + + DASHPAY_PROFILES { + BLOB identity_id PK "one row per identity" + BLOB profile_blob "bincode-encoded DashPayProfile" + } + + DASHPAY_PAYMENTS_OVERLAY { + BLOB identity_id PK + TEXT payment_id PK "transaction-level string key" + BLOB overlay_blob "bincode-encoded PaymentEntry" + } +``` + +## Diagram 3 — Contacts (DashPay social graph) + +One unified table for all three states of a DashPay contact relationship — the `state` column (`sent` / `received` / `established`) records the lifecycle stage. It roots on `wallet_id`; `IDENTITIES` is repeated here as a minimal placeholder to show that the `owner_id` / `contact_id` columns are Platform identity identifiers (32-byte blobs), not FK-enforced columns. + +```mermaid +erDiagram + WALLET_METADATA ||--o{ CONTACTS : "has" + IDENTITIES ||--o{ CONTACTS : "relates" + + WALLET_METADATA { + BLOB wallet_id PK "32-byte WalletId" + TEXT network + INTEGER birth_height + } + + IDENTITIES { + BLOB identity_id PK + } + + CONTACTS { + BLOB wallet_id PK + BLOB owner_id PK "32-byte identity owned by this wallet" + BLOB contact_id PK "32-byte counterparty identity" + TEXT state "sent | received | established" + BLOB outgoing_request "ContactRequest; set for sent + established" + BLOB incoming_request "ContactRequest; set for received + established" + TEXT alias "established-only (NULL when pending)" + TEXT note "established-only (NULL when pending)" + INTEGER is_hidden "established-only (NULL when pending)" + BLOB accepted_accounts "bincode-encoded Vec u32; established-only" + INTEGER updated_at "unixepoch() default" + } +``` + +> Note: `owner_id` and `contact_id` are Platform identity identifiers stored as BLOBs; they +> are NOT declared `FOREIGN KEY` columns. The relationship to `IDENTITIES` shown above is +> logical — enforced at the application layer, not by SQLite constraints. A pending row is +> `sent` XOR `received` and carries only the matching request blob; an `established` row sets +> both request blobs plus the four metadata columns. + +## Diagram 4 — Platform addresses + Asset locks (Platform L2 funding) + +Platform P2PKH address pool with its sync watermark, and the asset-lock lifecycle table. + +```mermaid +erDiagram + WALLET_METADATA ||--o{ PLATFORM_ADDRESSES : "tracks" + WALLET_METADATA ||--o| PLATFORM_ADDRESS_SYNC : "syncs" + WALLET_METADATA ||--o{ ASSET_LOCKS : "issues" + + WALLET_METADATA { + BLOB wallet_id PK "32-byte WalletId" + TEXT network + INTEGER birth_height + } + + PLATFORM_ADDRESSES { + BLOB wallet_id PK + BLOB address PK "20-byte HASH160 of the platform P2PKH address" + INTEGER account_index + INTEGER address_index + INTEGER balance "credits" + INTEGER nonce + } + + PLATFORM_ADDRESS_SYNC { + BLOB wallet_id PK "one row per wallet" + INTEGER sync_height "monotonically increasing" + INTEGER sync_timestamp + INTEGER last_known_recent_block + } + + ASSET_LOCKS { + BLOB wallet_id PK + BLOB outpoint PK "bincode-encoded OutPoint" + TEXT status "built | broadcast | is_locked | chain_locked | consumed" + INTEGER account_index + INTEGER identity_index + INTEGER amount_duffs + BLOB lifecycle_blob "bincode-encoded AssetLockEntry" + } +``` + +## Diagram 5 — Per-object metadata (KV) + +Per-object-type key/value metadata for arbitrary application-managed +data (aliases, flags, notes, sync hints, ordering — anything the host +app wants to stash alongside a wallet object). One dedicated `meta_*` +table per [`ObjectId`](./src/kv.rs) variant. `meta_global` has no parent +and survives wallet deletion. The other five carry **no foreign key**: +metadata may be written before its parent object is synced into its +typed table. An `AFTER DELETE` trigger on each parent table removes the +matching metadata when the object is deleted (soft cascade), so metadata +never outlives its object. The dashed edges below denote trigger-based +cleanup, not an FK relationship. + +```mermaid +erDiagram + WALLET_METADATA ||..o{ META_WALLET : "trigger cleanup" + WALLET_METADATA ||..o{ META_CONTACT : "trigger (via established contacts)" + WALLET_METADATA ||..o{ META_PLATFORM_ADDRESS : "trigger (via platform_addresses)" + IDENTITIES ||..o{ META_IDENTITY : "trigger cleanup" + TOKEN_BALANCES ||..o{ META_TOKEN : "trigger cleanup" + + META_GLOBAL { + TEXT key PK "1..=128 chars; no parent (survives wallet delete)" + BLOB value "opaque bytes; app picks its own serialization" + INTEGER updated_at "Unix epoch seconds; defaults to unixepoch()" + } + + META_WALLET { + BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete" + TEXT key PK + BLOB value + INTEGER updated_at + } + + META_IDENTITY { + BLOB identity_id PK "no FK; trigger cleanup on identities delete" + TEXT key PK + BLOB value + INTEGER updated_at + } + + META_TOKEN { + BLOB identity_id PK "no FK; trigger cleanup on token_balances delete" + BLOB token_id PK + TEXT key PK + BLOB value + INTEGER updated_at + } + + META_CONTACT { + BLOB wallet_id PK "no FK; trigger cleanup on established contacts delete" + BLOB owner_id PK + BLOB contact_id PK + TEXT key PK + BLOB value + INTEGER updated_at + } + + META_PLATFORM_ADDRESS { + BLOB wallet_id PK "no FK; trigger cleanup on platform_addresses delete" + BLOB address PK + TEXT key PK + BLOB value + INTEGER updated_at + } +``` + +> Note: every `meta_*` table's uniqueness comes straight from its +> composite `PRIMARY KEY` (id column(s) + `key`) — no partial indexes +> and no nullable scope column. The five typed tables carry no FK; their +> `AFTER DELETE` triggers also fire for parents removed by an FK cascade +> (SQLite does this natively, e.g. `delete_wallet` → `identities` cascade +> → `meta_identity` trigger), not only for directly-deleted parents. + +## Tables + +### `wallet_metadata` + +Root anchor for every per-wallet table. Deleting a row cascades to all +direct children; identity-owned children cascade through `identities`. + +- `wallet_id` — 32-byte `WalletId` blob; PRIMARY KEY. +- `network` — `"mainnet"` | `"testnet"` | `"devnet"` | `"regtest"`. +- `birth_height` — SPV scan start height; `0` when unknown. + +### `account_registrations` + +One row per account registered on a wallet (xpub + account type + index). +The `account_xpub_bytes` blob carries the full `AccountRegistrationEntry`; +the typed `account_type` / `account_index` columns mirror it for SQL +lookups without blob decoding. + +- PK: `(wallet_id, account_type, account_index)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `account_address_pools` + +Address-pool snapshot per `(wallet, account, pool_type)`. `pool_type` is +one of `external`, `internal`, `absent`, `absent_hardened`. + +- PK: `(wallet_id, account_type, account_index, pool_type)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `core_transactions` + +One row per transaction the wallet has seen. `height`, `block_hash`, and +`block_time` are NULL while the transaction is unconfirmed. `finalized` +is `1` once block context is present. + +- PK: `(wallet_id, txid)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- Index: `idx_core_transactions_height(wallet_id, height)`. + +### `core_utxos` + +One row per UTXO, spent or unspent. `spent_in_txid` is set to NULL +by a trigger when its referenced `core_transactions` row is deleted +(instead of a native `ON DELETE SET NULL`, which would also null the +NOT NULL `wallet_id` column). + +- PK: `(wallet_id, outpoint)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- Index: `idx_core_utxos_spent(wallet_id, spent)`. + +### `core_instant_locks` + +Instant-lock blobs for transactions that are broadcast but not yet +finalized. Rows are removed when the transaction becomes confirmed. + +- PK: `(wallet_id, txid)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `core_derived_addresses` + +Address-to-account-index map. Written before UTXOs in the same +transaction so the UTXO writer can resolve `account_index` by address. + +- PK: `(wallet_id, account_type, address)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- Index: `idx_core_derived_addresses_addr(wallet_id, address)`. + +### `core_sync_state` + +One row per wallet, holding monotonically-advancing SPV sync watermarks. +`last_processed_height` and `synced_height` are NULL until the first +block is processed. + +- PK: `wallet_id` (single-row-per-wallet). +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `identities` + +Platform identities, wallet-parented or orphan. `wallet_id` is nullable: +NULL means the identity was written before a parent wallet was registered +(orphan-to-parented promotion via COALESCE on upsert). `tombstoned = 1` +marks a logical delete; the row is retained for cascade integrity. + +- PK: `identity_id`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE` (nullable). +- Index: `idx_identities_wallet(wallet_id)`. + +### `identity_keys` + +Public identity keys only — no private material (NFR-10). The +`public_key_blob` is a custom wire format (`IdentityKeyWire`) that +pre-encodes the `IdentityPublicKey` via bincode 2 native `Encode/Decode` +to work around a serde-tag incompatibility. + +- PK: `(identity_id, key_id)`. +- FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. +- Index: `idx_identity_keys_identity(identity_id)`. + +### `contacts` + +All DashPay contact relationships in one table, keyed by lifecycle +`state`. `owner_id` is always the wallet's identity; `contact_id` is the +counterparty. A pending relationship is `sent` (we sent the request) XOR +`received` (we received it) and carries only the matching request blob; an +`established` relationship carries both `outgoing_request` and +`incoming_request` plus the four metadata columns (`alias`, `note`, +`is_hidden`, `accepted_accounts`, NULL while pending). The request columns +hold a bincode-encoded `ContactRequest`; `accepted_accounts` holds a +bincode-encoded `Vec`. + +- PK: `(wallet_id, owner_id, contact_id)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- `state` CHECK: sourced from `sqlite::schema::contacts::CONTACT_STATE_LABELS`. + +### `platform_addresses` + +Platform P2PKH address pool entries. `address` stores the 20-byte +HASH160; `balance` and `nonce` are the last-synced values from the +Platform layer. + +- PK: `(wallet_id, address)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `platform_address_sync` + +Per-wallet watermark for platform address sync. All three height/timestamp +fields advance monotonically (new values are `max(current, incoming)`). + +- PK: `wallet_id` (single-row-per-wallet). +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `asset_locks` + +Lifecycle tracking for asset-lock outpoints. `status` is a queryable +text column; `lifecycle_blob` carries the full `AssetLockEntry`. Consumed +locks are removed via `AssetLockChangeSet::removed`, not retained with a +consumed status. + +- PK: `(wallet_id, outpoint)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `token_balances` + +Per-identity token balance cache, keyed by `(identity_id, token_id)`. +Cascade flows `wallet_metadata → identities → token_balances` through the +nullable `identities.wallet_id` link; no direct `wallet_id` column exists. + +- PK: `(identity_id, token_id)`. +- FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. + +### `dashpay_profiles` + +At most one DashPay profile blob per identity. `None` profile maps to a +DELETE rather than a NULL blob — the row is absent, not nulled. + +- PK: `identity_id` (single-row-per-identity). +- FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. + +### `dashpay_payments_overlay` + +Payment overlay entries for DashPay, keyed by transaction-level +`payment_id` string. Cascade flows through `identities` as with +`token_balances`. + +- PK: `(identity_id, payment_id)`. +- FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. + +### Per-object metadata (`meta_*`) + +Six dedicated key/value tables for app-managed metadata, one per +[`ObjectId`](./src/kv.rs) variant. Values are opaque BLOBs — the host app +picks its own serialization (bincode, JSON, protobuf, raw bytes). Shared +across all six: `key` is `TEXT` with `CHECK (length(key) BETWEEN 1 AND +128)`; `value` is `BLOB NOT NULL`; `updated_at` defaults to `unixepoch()` +and is refreshed on every `INSERT … ON CONFLICT DO UPDATE`. Uniqueness +comes from each table's composite `PRIMARY KEY` (id column(s) + `key`). +Public API lives in [`src/kv.rs`](./src/kv.rs); the SQLite implementation +is in [`src/sqlite/kv.rs`](./src/sqlite/kv.rs). + +Unlike every other per-wallet table, the five typed `meta_*` tables carry +**no foreign key**: a write succeeds before its parent object exists, so +host apps can attach metadata independently of sync ordering (and a +global-config persister can write to typed scopes whose parent tables +stay empty). Cleanup is instead a soft cascade — an `AFTER DELETE` +trigger on each parent removes the matching metadata when the object is +deleted, so metadata never outlives its object. SQLite fires these +triggers for parents removed by an FK cascade too, so deleting a wallet +cleans its metadata transitively. + +#### `meta_global` + +Global metadata with no parent — survives every wallet delete. + +- PK: `key`. +- No foreign key, no trigger. + +#### `meta_wallet` + +Per-wallet metadata. Writable before the wallet exists. + +- PK: `(wallet_id, key)`. +- No FK. Cleanup: `cascade_meta_wallet_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`). + +#### `meta_identity` + +Per-identity metadata. Writable before the identity exists. + +- PK: `(identity_id, key)`. +- No FK. Cleanup: `cascade_meta_identity_on_identity_delete` (AFTER DELETE ON `identities`). + +#### `meta_token` + +Per-token-balance metadata. Writable before the token balance exists. + +- PK: `(identity_id, token_id, key)`. +- No FK. Cleanup: `cascade_meta_token_on_token_balance_delete` (AFTER DELETE ON `token_balances`). + +#### `meta_contact` + +Per-established-contact metadata. Writable before the contact exists. + +- PK: `(wallet_id, owner_id, contact_id, key)`. +- No FK. Cleanup: `cascade_meta_contact_on_contact_delete` (AFTER DELETE ON `contacts` WHEN `state = 'established'`). + +#### `meta_platform_address` + +Per-platform-address metadata. `address` is an opaque `BLOB`. Writable +before the address exists. + +- PK: `(wallet_id, address, key)`. +- No FK. Cleanup: `cascade_meta_platform_address_on_address_delete` (AFTER DELETE ON `platform_addresses`). + +## Enum-domain CHECK constraints + +Six TEXT columns carry a `CHECK (col IN (...))` clause whose IN-list is +built at migration time from `pub(crate) const *_LABELS` arrays declared +next to each writer function. Five mirror an upstream Rust enum; the +sixth (`contacts.state`) is a synthetic lifecycle label naming which +`ContactChangeSet` slot a row came from: + +| Table | Column | Source-of-truth const | +|---|---|---| +| `wallet_metadata` | `network` | `sqlite::schema::wallet_meta::NETWORK_LABELS` | +| `account_registrations` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | +| `account_address_pools` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | +| `account_address_pools` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` | +| `core_derived_addresses` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | +| `asset_locks` | `status` | `sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS` | +| `contacts` | `state` | `sqlite::schema::contacts::CONTACT_STATE_LABELS` | + +The const arrays are the single source of truth shared by the writer +mapping functions (`network_to_str`, `account_type_db_label`, +`pool_type_db_label`, `status_str`, `contact_state_db_label`) and the +migration's CHECK clauses. +Per-module `*_labels_match_enum` unit tests enforce set-equality +between each const and the writer's codomain — drift (a renamed/added +upstream variant) fails the test rather than landing as silent garbage +in the database. The label inventories are intentionally not duplicated +in this document; the source files are canonical. + +### Upstream-enum coupling + +Three of the persisted enums live in the external `rust-dashcore` +crate (`key_wallet::Network`, `key_wallet::account::AccountType`, +`key_wallet::managed_account::address_pool::AddressPoolType`); the +fourth (`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`) +is in-tree and carries a `# Schema coupling` rustdoc block. + +Because the upstream definitions cannot be edited from this repository, +the coupling is enforced from the local side instead, by three +mechanisms working together: + +1. **Writer rustdoc** in each `sqlite::schema::*` module names the + upstream enum path so an IDE jump-to-definition lands at it. +2. **Exhaustive `match` arms** in the parity-test variant lists + (`all_*_variants` functions) cause an upstream-added variant to + fail compilation here, forcing a writer + LABELS update. +3. **`*_labels_match_enum` unit tests** assert set-equality between + each `*_LABELS` array and the writer's codomain. + +TODO(rust-dashcore): once the upstream `key_wallet` crate is vendored +or the project gains push access there, mirror the in-tree +`AssetLockStatus` `# Schema coupling` doc block on the three upstream +enums so a developer editing them upstream sees the constraint without +having to grep this repo. + +## Foreign-key conventions + +- All direct-child `wallet_id` columns are `BLOB(32)` references to + `wallet_metadata.wallet_id` with `ON DELETE CASCADE`. +- `identities.wallet_id` is the single nullable FK: NULL means orphan + (no parent wallet registered yet). The orphan-to-parented promotion + uses `COALESCE(identities.wallet_id, excluded.wallet_id)` on upsert. +- Identity-owned tables (`identity_keys`, `token_balances`, + `dashpay_profiles`, `dashpay_payments_overlay`) have no `wallet_id` + column. Cascade reaches them via `identities(identity_id)`. +- `core_utxos.spent_in_txid` is cleared by the `setnull_core_utxos_on_tx_delete` + trigger rather than a native `ON DELETE SET NULL` FK, because SQLite would null + every column of a composite FK on SET NULL — including the NOT NULL `wallet_id`. +- The five typed `meta_*` tables carry **no FK** (writes may precede the parent); + cleanup is a per-parent `AFTER DELETE` trigger (soft cascade) that SQLite fires + for FK-cascade-deleted parents too, so it cleans transitively. +- `PRAGMA foreign_keys = ON` is set and verified on every read-write connection open. + +## Triggers + +| Trigger | Fires | Action | +|---|---|---| +| `setnull_core_utxos_on_tx_delete` | AFTER DELETE ON `core_transactions` | NULL `core_utxos.spent_in_txid` for the deleted tx | +| `cascade_meta_wallet_on_wallet_delete` | AFTER DELETE ON `wallet_metadata` | delete matching `meta_wallet` rows | +| `cascade_meta_identity_on_identity_delete` | AFTER DELETE ON `identities` | delete matching `meta_identity` rows | +| `cascade_meta_token_on_token_balance_delete` | AFTER DELETE ON `token_balances` | delete matching `meta_token` rows | +| `cascade_meta_contact_on_contact_delete` | AFTER DELETE ON `contacts` WHEN `state = 'established'` | delete matching `meta_contact` rows | +| `cascade_meta_platform_address_on_address_delete` | AFTER DELETE ON `platform_addresses` | delete matching `meta_platform_address` rows | + +## Migrations + +| Version | File | Description | +|---|---|---| +| V001 | `V001__initial.rs` | Full schema: all 23 tables (including the six `meta_*` per-object metadata tables), every index, and six triggers (`setnull_core_utxos_on_tx_delete` + the five `meta_*` soft-cascade triggers) | diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md new file mode 100644 index 00000000000..173810c32b1 --- /dev/null +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -0,0 +1,217 @@ +# 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. + +## The `secrets` submodule + +`platform_wallet_storage::secrets` is part of the crate's default +feature set. The consumer entry point is `SecretStore`; the upstream +`keyring_core::api::{CredentialApi, CredentialStoreApi}` (shipped by +`keyring-core 1.0.0`) is the internal backend SPI. This crate +contributes backends and zeroizing wrappers, not the trait surface. + +### Consumer API: `SecretStore` + +`SecretStore` is the public, never-leaking front door. `get` yields a +zeroizing `SecretBytes` (a raw `Vec` never crosses the boundary); +`set` takes `&SecretBytes` so a caller cannot pass an unwrapped buffer. +Errors surface as the typed `SecretStoreError` — losslessly for the file +arm, so `WrongPassphrase` vs `Corruption` vs `Busy` stay distinct. + +```rust +use platform_wallet_storage::secrets::{SecretBytes, SecretStore, SecretString, WalletId}; + +let store = SecretStore::file("/var/lib/wallet/secrets.pwsvault", SecretString::new("pw"))?; +let wallet = WalletId::from(wallet_id); +store.set(&wallet, "mnemonic", &SecretBytes::from_slice(b"abandon ability ..."))?; +let plaintext: Option = store.get(&wallet, "mnemonic")?; // never a bare Vec +store.delete(&wallet, "mnemonic")?; // idempotent +``` + +`SecretStore::file` takes the vault FILE path (operator picks the +filename); the parent directory is materialized on the first write. +Use `SecretStore::os()` for the platform OS keyring arm instead of +`SecretStore::file(..)`. + +### Internal SPI + +Below `SecretStore`, `EncryptedFileStore` and `default_credential_store` +expose the raw `keyring_core` SPI directly; their `keyring_core::Error` +projection is **lossy and string-only** (the typed distinction lives on +the `SecretStore` path). SPI consumers re-wrap the bare `Vec` from +`CredentialApi::get_secret` via `SecretBytes::new(...)` at the seam. + +### Key shape + +| upstream field | this crate's mapping | +|---|---| +| `service` | `"dash.platform-wallet-storage/" + hex(wallet_id)` (`SERVICE_PREFIX` + 64 hex chars) — one keyring "service" namespace per wallet | +| `user` | `label`, validated against `^[A-Za-z0-9._-]{1,64}$` (SEC-REQ-4.3) before reaching the SPI; allowlist excludes `/`, `:`, space, NUL, non-ASCII | + +`WalletId` is a fixed 32-byte newtype. `validated_label` runs at +`CredentialStoreApi::build` time AND at every `CredentialApi` +operation (defence in depth — credentials are long-lived). + +### Memory hygiene at the seam + +`SecretStore::get` returns `Option` — a raw `Vec` +never crosses the public boundary. Internally, the upstream SPI returns +plaintext as `Vec` from `CredentialApi::get_secret`; that result is +wrapped into `SecretBytes::new(...)` **immediately**, with no named +intermediate `Vec` binding (Smythe EDIT-1). `SecretBytes::new` takes the +`Vec` by value and `std::mem::take`s it into a `Zeroizing>` — +no copy of the bare buffer ever survives past the constructor +expression, so the bare-`Vec` exposure window is zero statements. The +wrapper is also best-effort `mlock`ed and `Debug` is redacted. + +`SecretStore::set` takes `&SecretBytes`, exposing the wrapped bytes to +the SPI's `set_secret(&[u8])` only at the last moment; no long-lived +unwrapped copy is allocated. + +### Backends + +- **File vault (`SecretStore::file` / `EncryptedFileStore`)** — Argon2id + (memory ≥ 19 MiB, t ≥ 2, defaults 64 MiB / t=3) + XChaCha20-Poly1305 + AEAD with a random 24-byte XNonce per entry. AAD binds ciphertext to + `format_version ‖ wallet_id ‖ label` so a blob moved between slots + (or across wallets) fails the tag. A header-stored passphrase- + verification token is unsealed before any entry is touched + (mixed-key-corruption guard). The vault is ONE `serde_json` document + covering every wallet in the store — a single passphrase, a single + KDF salt, a single cross-process advisory lock (`.lock` + sidecar). Inside, entries are nested `BTreeMap>`. The file is written atomically via + `tempfile::NamedTempFile::persist` (cross-platform + replace-over-existing) at mode 0600 on Unix; rekey rotates the WHOLE + store under a fresh passphrase + salt atomically with no `.bak` + (SEC-REQ-2.2.x). One file, one passphrase, one lock — a multi-wallet + store cannot lock its other wallets out by construction. Errors + surface as the typed `SecretStoreError` through `SecretStore`. +- **OS keyring (`SecretStore::os` / `default_credential_store`)** — + returns an `Arc` over the + platform's default credential store. The backend on Linux/FreeBSD is + `dbus-secret-service-keyring-store`; on macOS + `apple-native-keyring-store`; on Windows + `windows-native-keyring-store`. Fail-closed with + `keyring_core::Error::NoDefaultStore` on headless / unknown OS + (SEC-REQ-2.1.3 / AR-4) — never a silent plaintext fallback. Through + `SecretStore`, keyring failures project to + `SecretStoreError::OsKeyring { kind }`, a non-secret discriminant. + + **Headless caveat (Linux/FreeBSD).** Secret Service requires a D-Bus + session and an unlocked collection; headless / SSH / CI hosts + frequently lack it, in which case `SecretStore::os()` fails closed + with `NoDefaultStore`. Callers that need durable storage on a + headless host should pin `SecretStore::file(...)` (encrypted-file + vault) instead of relying on the OS keyring. +- **Tests** — integration tests construct a tempdir-backed + `EncryptedFileStore` directly via + `EncryptedFileStore::open(tempfile::tempdir()?.path().join("vault.pwsvault"), SecretString::new("..."))`, + or use the public `SecretStore::file(path, passphrase)` constructor. + No special feature flag is required; both are available under the default + `secrets` feature. + +Backend selection is an explicit operator decision; there is no +automatic fallback between backends. + +### Error surface + +`SecretStore` returns the typed `SecretStoreError`. For the file arm this +is **lossless**: `WrongPassphrase`, `Corruption`, `Busy`, `KdfFailure`, +`VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, and +`InvalidLabel` are distinct typed variants. For the OS arm, +`keyring_core::Error` projects best-effort into +`SecretStoreError::OsKeyring { kind: OsKeyringErrorKind }`, a payload-free +discriminant — keyring variants carrying raw bytes (`BadEncoding`, +`BadDataFormat`) are collapsed so their bytes never enter the error +(CWE-209/CWE-532). + +The internal SPI projection `From for +keyring_core::Error` keeps the `WrongPassphrase` / `Busy` variants +recoverable: they ride in `NoStorageAccess` with the typed +`SecretStoreError` boxed as the source, so an SPI-only consumer can recover +them via `err.source().and_then(|s| s.downcast_ref::())`. +The `BadStoreFormat` group (`Corruption`, `KdfFailure`, +`VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, `Decrypt`, +`OsKeyring`) has no box slot and carries only a secret-free string; those +remain fully typed on the `SecretStore` path. + +Per Smythe EDIT-2, `keyring_core::Error` is safe to `Display` +(`{ }`-format), but `{:?}`-format embeds `BadEncoding(Vec)` / +`BadDataFormat(Vec, _)` payloads — those variants are NEVER +constructed by our backends with secret bytes, and +`tests/secrets_guard.rs` enforces that no debug-format pairs with +`keyring_core::Error` inside `src/secrets/`. + +## 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 `src/secrets/` directory is exempt + by design (its own positive guard below covers it). +- **`tests/secrets_guard.rs`**: positive secret-leak guard for + `src/secrets/`. Forbids logging/formatting sinks that pair with + `expose_secret(...)` on the same logical statement (SEC-REQ-4.5.1), + AND forbids `{:?}`-debug-format paired with `keyring_core::Error` + (Smythe EDIT-2). +- **`tests/secrets_api.rs`**: shape guards — `CredentialApi::get_secret` + re-wraps through `SecretBytes::new` (EDIT-1), redacting `Debug` on + `SecretBytes`/`SecretString`, no `Box` in `src/secrets/` + (TC-082 parity). +- **`tests/secrets_off_state.rs`**: runtime guard that + `--no-default-features --features sqlite,cli` builds the persister + without pulling in the `secrets` module (D4). +- **NFR-4 / TC-082** (`tests/sqlite_persist_roundtrip.rs::tc082_no_box_dyn_error_in_src`): + all public method signatures use concrete error types + (`WalletStorageError`, `PersistenceError`) — never + `Box` — so a future leak is caught by `grep`. + +The CI advisory check runs `rustsec/audit-check` over `Cargo.lock`; +because `secrets` is in the default feature set, the pinned +`argon2` / `chacha20poly1305` / `zeroize` / `subtle` / `getrandom` +(the `OsRng` source for the salt + per-entry nonces, specified as the +semver range `getrandom = "0.2"` and lock-pinned to 0.2.17 by +lock-file convention) / `region` / `keyring-core` / per-platform store +crate versions are unconditionally in the lockfile and therefore +unconditionally in audit scope (SEC-REQ-4.7). + +## 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. + +## Future work — maintenance CLI + +A unified `platform-wallet-storage secrets ` CLI is planned as a follow-up to give operators a way to inspect and manage the secret backends without writing custom code. Out of scope for this PR (#3672); tracked separately. Two commands matter: + +- **`secrets probe`** — set/get/delete a `__probe__` entry under `SERVICE_PREFIX`. Works uniformly on **all** backends (Secret Service, macOS Keychain, Windows Credential Manager) because it only uses single-entry CRUD. Confirms backend liveness + write-path responsiveness — the canary command for "is the keyring actually wired up on this machine?". Cheap to implement (~30 lines). +- **`secrets list [--filter ]`** — enumerate `(wallet_id, label)` pairs in the store. Trivial on the file vault (iterate the in-memory `BTreeMap`). On the OS arm: works on Secret Service, macOS Keychain, and Windows Credential Manager via `CredentialStoreApi::search`. Operators on headless Linux without a Secret Service session must select the file vault explicitly. + +Other planned subcommands: `secrets put