From 8b99ea8f292d29998110ecac1994e7ff0040d084 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:38:51 +0000 Subject: [PATCH 1/3] fix(deps): update platform while preserving existing profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin Platform v4.2-dev at e3cd7cf (4.2.0-dev.8) and adapt wallet APIs. Automatically bridge the previous PR's database lineage for both app and network stores with retained backups, strict staged loading and atomic reconstruction. Preserve live identity metadata during ownership promotion. Handle swept transaction events and enable the passing Max-send regression. Document retained functionality, remaining upstream gaps and audit results. Validation: cargo fmt --all; 2520 library tests passed; exact CI Clippy flags --all-features --all-targets -- -D warnings passed. Co-Authored-By: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- CHANGELOG.md | 9 + Cargo.lock | 232 +++++----- Cargo.toml | 19 +- .../2026-09-10-platform-pin/upgrade-notes.md | 85 ++++ src/backend_task/contract.rs | 1 + src/backend_task/dashpay/contact_requests.rs | 1 + src/backend_task/document.rs | 1 + src/backend_task/error.rs | 9 +- .../identity/discover_identities.rs | 1 + src/backend_task/identity/load_identity.rs | 1 + .../identity/load_identity_by_dpns_name.rs | 2 + .../identity/load_identity_from_wallet.rs | 1 + .../refresh_loaded_identities_dpns_names.rs | 1 + .../identity/register_dpns_name.rs | 1 + src/backend_task/platform_info.rs | 3 + src/context/identity_db.rs | 42 +- src/context/mod.rs | 44 +- src/context/wallet_lifecycle/bootstrap.rs | 16 +- src/context/wallet_lifecycle/tests.rs | 104 ++++- src/ui/tokens/view_token_claims_screen.rs | 1 + src/wallet_backend/dashpay.rs | 5 +- src/wallet_backend/event_bridge.rs | 33 ++ src/wallet_backend/identity_ops.rs | 9 +- src/wallet_backend/mod.rs | 29 +- src/wallet_backend/payments.rs | 17 +- .../platform_compatibility/engine.rs | 432 ++++++++++++++++++ .../platform_compatibility/engine/tests.rs | 332 ++++++++++++++ .../fixtures/67d4ef3.sql | 398 ++++++++++++++++ .../platform_compatibility/fixtures/README.md | 27 ++ .../fixtures/e3cd7cf.sql | 407 +++++++++++++++++ .../fixtures/public-rows.sql | 11 + .../platform_compatibility/mod.rs | 116 +++++ src/wallet_backend/snapshot.rs | 12 + 33 files changed, 2202 insertions(+), 200 deletions(-) create mode 100644 docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md create mode 100644 src/wallet_backend/platform_compatibility/engine.rs create mode 100644 src/wallet_backend/platform_compatibility/engine/tests.rs create mode 100644 src/wallet_backend/platform_compatibility/fixtures/67d4ef3.sql create mode 100644 src/wallet_backend/platform_compatibility/fixtures/README.md create mode 100644 src/wallet_backend/platform_compatibility/fixtures/e3cd7cf.sql create mode 100644 src/wallet_backend/platform_compatibility/fixtures/public-rows.sql create mode 100644 src/wallet_backend/platform_compatibility/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index af7ae5630..22515e2af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -404,6 +404,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Platform updated to `4.2.0-dev.8`** (`v4.2-dev`, `e3cd7cf`): existing + databases from the previously pinned PR are upgraded automatically with a + retained backup and verified data transfer. Both app preferences and network + wallet data are covered. Identity ownership changes preserve saved metadata, + and swept transactions leave the displayed history. The single-UTXO Max-send + regression test now passes and is enabled. See the + [upgrade review](docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md) for + compatibility details and functionality still pending upstream. + - **A funding transaction found again on the network is now labelled honestly**: when the app rediscovers a saved funding transaction from the chain rather than tracking it from the start, it can tell that the network confirmed it but diff --git a/Cargo.lock b/Cargo.lock index 895dd4eac..d06192377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1876,8 +1876,8 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "dash-platform-macros", "futures-core", @@ -1978,8 +1978,8 @@ dependencies = [ [[package]] name = "dash-async" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "futures", "thiserror 2.0.18", @@ -1990,8 +1990,8 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "dash-async", "dpp", @@ -2082,7 +2082,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode 2.0.1", "bincode_derive", @@ -2093,15 +2093,15 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "dash-network", ] [[package]] name = "dash-platform-macros" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "heck", "quote", @@ -2110,8 +2110,8 @@ dependencies = [ [[package]] name = "dash-platform-queries" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "dapi-grpc", "dash-context-provider", @@ -2128,8 +2128,8 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "arc-swap", "async-trait", @@ -2168,7 +2168,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "async-trait", "chrono", @@ -2197,7 +2197,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "anyhow", "base64-compat", @@ -2223,12 +2223,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "dashcore-rpc-json", "hex", @@ -2241,7 +2241,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode 2.0.1", "dashcore", @@ -2256,7 +2256,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode 2.0.1", "dashcore-private", @@ -2266,8 +2266,8 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "platform-value", "platform-version", @@ -2277,8 +2277,8 @@ dependencies = [ [[package]] name = "data-contracts" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "dashpay-contract", "document-history-contract", @@ -2356,7 +2356,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -2546,8 +2545,8 @@ dependencies = [ [[package]] name = "document-history-contract" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "platform-value", "platform-version", @@ -2575,8 +2574,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "platform-value", "platform-version", @@ -2586,8 +2585,8 @@ dependencies = [ [[package]] name = "dpp" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "anyhow", "async-trait", @@ -2636,8 +2635,8 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "proc-macro2", "quote", @@ -2646,8 +2645,8 @@ dependencies = [ [[package]] name = "drive" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2672,8 +2671,8 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -3696,7 +3695,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" [[package]] name = "gl_generator" @@ -3894,7 +3893,7 @@ dependencies = [ [[package]] name = "grovedb" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "bincode 2.0.1", "bincode_derive", @@ -3917,7 +3916,7 @@ dependencies = [ [[package]] name = "grovedb-bulk-append-tree" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "bincode 2.0.1", "blake3", @@ -3934,7 +3933,7 @@ dependencies = [ [[package]] name = "grovedb-commitment-tree" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "blake3", "grovedb-bulk-append-tree", @@ -3960,7 +3959,7 @@ dependencies = [ [[package]] name = "grovedb-costs" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "integer-encoding", "intmap", @@ -3970,7 +3969,7 @@ dependencies = [ [[package]] name = "grovedb-dense-fixed-sized-merkle-tree" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "bincode 2.0.1", "blake3", @@ -3997,7 +3996,7 @@ dependencies = [ [[package]] name = "grovedb-element" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "bincode 2.0.1", "bincode_derive", @@ -4012,7 +4011,7 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "grovedb-costs 5.0.1", "hex", @@ -4045,7 +4044,7 @@ dependencies = [ [[package]] name = "grovedb-merk" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "bincode 2.0.1", "bincode_derive", @@ -4067,7 +4066,7 @@ dependencies = [ [[package]] name = "grovedb-merkle-mountain-range" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "bincode 2.0.1", "blake3", @@ -4086,7 +4085,7 @@ dependencies = [ [[package]] name = "grovedb-path" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "hex", ] @@ -4094,7 +4093,7 @@ dependencies = [ [[package]] name = "grovedb-query" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "bincode 2.0.1", "byteorder", @@ -4111,16 +4110,16 @@ version = "4.0.0" source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "thiserror 2.0.18", - "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "versioned-feature-core", ] [[package]] name = "grovedb-version" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "thiserror 2.0.18", - "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "versioned-feature-core", ] [[package]] @@ -4135,7 +4134,7 @@ dependencies = [ [[package]] name = "grovedb-visualize" version = "5.0.1" -source = "git+https://github.com/dashpay/grovedb?rev=6c882c3ee7d2c331f1feda2eb4223add9a6f0e45#6c882c3ee7d2c331f1feda2eb4223add9a6f0e45" +source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "hex", "itertools 0.14.0", @@ -5026,7 +5025,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "async-trait", "base58ck", @@ -5049,7 +5048,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "async-trait", "dashcore", @@ -5072,8 +5071,8 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "platform-value", "platform-version", @@ -5274,10 +5273,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[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 = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "platform-value", "platform-version", @@ -6499,8 +6507,8 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "aes", "cbc", @@ -6512,8 +6520,8 @@ dependencies = [ [[package]] name = "platform-serialization" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6521,8 +6529,8 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "proc-macro2", "quote", @@ -6532,8 +6540,8 @@ dependencies = [ [[package]] name = "platform-value" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6552,19 +6560,19 @@ dependencies = [ [[package]] name = "platform-version" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "bincode 2.0.1", "grovedb-version 5.0.1", "thiserror 2.0.18", - "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", + "versioned-feature-core", ] [[package]] name = "platform-versioning" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "proc-macro2", "quote", @@ -6573,8 +6581,8 @@ dependencies = [ [[package]] name = "platform-wallet" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "arc-swap", "async-trait", @@ -6607,8 +6615,8 @@ dependencies = [ [[package]] name = "platform-wallet-storage" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "apple-native-keyring-store", "argon2", @@ -6630,14 +6638,16 @@ dependencies = [ "memsec", "platform-wallet", "refinery", + "refinery-core", + "region", "rusqlite", "schemars", "serde", "serde_json", - "sha2", "subtle", "tempfile", "thiserror 1.0.69", + "time", "tracing", "tracing-subscriber", "windows-native-keyring-store", @@ -6891,7 +6901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", - "itertools 0.14.0", + "itertools 0.10.5", "log", "multimap", "petgraph", @@ -6912,7 +6922,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -7288,9 +7298,9 @@ dependencies = [ [[package]] name = "refinery" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee5133e5b207e5703c2a4a9dc9bd8c8f2cc74c4ac04ca5510acaa907012c77ac" +checksum = "6e2a344cdb48871e27addeafbbaffab8828cc12cec2b9041119e9bea0c0f551a" dependencies = [ "refinery-core", "refinery-macros", @@ -7298,9 +7308,9 @@ dependencies = [ [[package]] name = "refinery-core" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023a2a96d959c9b5b5da78e965bfdb1363b365bf5e84531a67d0eee827a702a3" +checksum = "24eeafd893124f29183dd6afa9137a27e7bef59250223b8b660005279c60aea4" dependencies = [ "async-trait", "cfg-if", @@ -7316,9 +7326,9 @@ dependencies = [ [[package]] name = "refinery-macros" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56c2e960c8e47c7c5c30ad334afea8b5502da796a59e34d640d6239d876d924" +checksum = "a90cea6d11a9a4e8a85a884b6305461004101b28ca65dd35ec028e009a898e16" dependencies = [ "proc-macro2", "quote", @@ -7356,6 +7366,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 = "renderdoc-sys" version = "1.1.0" @@ -7593,8 +7615,8 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "backon", "chrono", @@ -7619,8 +7641,8 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "arc-swap", "dash-async", @@ -8769,12 +8791,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -8784,15 +8805,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", @@ -8861,8 +8882,8 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "platform-value", "platform-version", @@ -9638,11 +9659,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "898c0ad500fdb1914df465a2c729fce33646ef65dfbbbd16a6d8050e0d2404df" -[[package]] -name = "versioned-feature-core" -version = "1.0.0" -source = "git+https://github.com/dashpay/versioned-feature-core#560157096c8405a46ce0f21a2e7e1bd11d6625b4" - [[package]] name = "versions" version = "7.0.0" @@ -9706,8 +9722,8 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "platform-value", "platform-version", @@ -10288,7 +10304,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -11028,8 +11044,8 @@ dependencies = [ [[package]] name = "withdrawals-contract" -version = "4.2.0-dev.2" -source = "git+https://github.com/dashpay/platform?rev=67d4ef3f6340a1e983229b6870ef60cf7573602a#67d4ef3f6340a1e983229b6870ef60cf7573602a" +version = "4.2.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index c5a7e02b2..b5c154304 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ eframe = { version = "0.35.0", features = ["persistence", "wgpu"] } base64 = "0.22.1" # TODO: GHSA-7gcf-g7xr-8hxj (serde_with <3.21.0) is unfixable from here — the 2.x pin lives in # dashcore-rpc-json (dashpay/rust-dashcore, rpc-json/Cargo.toml). Re-check when these pins move. -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "67d4ef3f6340a1e983229b6870ef60cf7573602a", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "e3cd7cf5a34dd69b59d633e532a1ac570870b03f", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -30,18 +30,17 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "67d4ef3f6340a1e "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "67d4ef3f6340a1e983229b6870ef60cf7573602a" } -platform-wallet = { git = "https://github.com/dashpay/platform", rev = "67d4ef3f6340a1e983229b6870ef60cf7573602a", features = [ +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "e3cd7cf5a34dd69b59d633e532a1ac570870b03f" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "e3cd7cf5a34dd69b59d633e532a1ac570870b03f", features = [ "serde", "shielded", ] } -# `secret-serde` backs `model::secret::Secret`'s `Deserialize`: the upstream +# `serde` backs `model::secret::Secret`'s `Deserialize`: the upstream # visitor copies a borrowed `&str` straight into guarded memory instead of -# routing through a transient `String`. `secret-schemars` is enabled by the -# `mcp`/`cli` features, which are the only ones that generate tool schemas. -platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "67d4ef3f6340a1e983229b6870ef60cf7573602a", features = [ +# routing through a transient `String`. Secret schemas are included by `secrets`. +platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "e3cd7cf5a34dd69b59d633e532a1ac570870b03f", features = [ "shielded", - "secret-serde", + "serde", ] } zip32 = "0.2.0" grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" } @@ -124,8 +123,8 @@ raw-cpuid = "11.5.0" default = [] testing = [] bench = [] -mcp = ["dep:rmcp", "rmcp/server", "rmcp/macros", "rmcp/transport-streamable-http-server", "dep:axum", "platform-wallet-storage/secret-schemars"] -cli = ["dep:rmcp", "rmcp/server", "rmcp/macros", "rmcp/client", "rmcp/transport-io", "rmcp/transport-streamable-http-client-reqwest", "dep:clap", "dep:clap_complete", "platform-wallet-storage/secret-schemars"] +mcp = ["dep:rmcp", "rmcp/server", "rmcp/macros", "rmcp/transport-streamable-http-server", "dep:axum"] +cli = ["dep:rmcp", "rmcp/server", "rmcp/macros", "rmcp/client", "rmcp/transport-io", "rmcp/transport-streamable-http-client-reqwest", "dep:clap", "dep:clap_complete"] headless = ["cli", "mcp"] [dev-dependencies] diff --git a/docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md b/docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md new file mode 100644 index 000000000..2a38aca98 --- /dev/null +++ b/docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md @@ -0,0 +1,85 @@ +# Platform development pin upgrade + +Reviewed on 2026-09-10. The four Platform dependencies move together from +`67d4ef3f6340a1e983229b6870ef60cf7573602a` (`4.2.0-dev.2`, PR #3968) +to `e3cd7cf5a34dd69b59d633e532a1ac570870b03f` (`4.2.0-dev.8`, `v4.2-dev`). +The transitive rust-dashcore revision moves from `3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a` +to `93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd`. + +## What survived the PR split + +| Area | Result at the new pin | +| --- | --- | +| SQLite persistence and seedless rehydration | Included through [#3968](https://github.com/dashpay/platform/pull/3968), with a different migration lineage. | +| Typed persistence errors | Included through [#4586](https://github.com/dashpay/platform/pull/4586). Store retry eligibility is now an explicit backend contract. | +| Guarded editable secrets and password envelopes | Retained, including `SecretString::replace_range`; Argon2 working memory wiping improves. | +| Large operating-system memory pages | Secret storage now refuses page sizes above 16 KiB. Hosts using 64 KiB pages are not covered by the local Linux checks. | +| Secret deserialization/schema features | `secret-serde` becomes `serde`; schemas are included with `secrets`. | +| Provider-key reconstruction | Retained; the proposed FFI deduplication is not required by DET. | +| Contact-account scan coverage | [#4587](https://github.com/dashpay/platform/pull/4587) remains open. Upstream inserts contact accounts without invalidating `account_generation` and prior filter-scan coverage. DET does invalidate these for new accounts created at bootstrap/unlock; it cannot retroactively cover an account already inserted by recurring upstream sync. End-to-end contact-payment consequences still need network testing. | +| FFI asset-lock proof size gate | [#4585](https://github.com/dashpay/platform/pull/4585) remains open and the old gate is absent. DET consumes Rust wallet APIs, not this FFI entry point. | + +## Compatibility work + +- Document queries explicitly use an empty sub-query list, retaining their + existing single-query behavior. +- Persistence failures retain their typed source and use the new store-failure + classification; exhaustive wallet error handling includes new variants. +- Swept transactions are removed from DET's displayed transaction history while + retaining unrelated transactions and other wallets' history. +- The existing single-UTXO Max-send regression now passes; its test is enabled + in the ordinary test suite (DET #909 / rust-dashcore #911). +- [#4496](https://github.com/dashpay/platform/pull/4496) replaces identity + tombstones with hard deletion and metadata cascades. Ownership reconciliation + now preserves a still-listed identity until its wallet takes ownership, + including when that promotion must wait for a later reconciliation. +- Existing PR-pin databases use a compatibility bridge: their V001 checksum is + different and their V003 unified schema precedes the new branch's V009. + Rewriting migration checksums alone is invalid because the materialized schemas + also differ. + +## Existing data + +Both `det-app.sqlite` and the per-network wallet database use the bridge. It +recognizes the exact old migration history and materialized schema, retains a +SQLite backup beside the original (`.platform-67d4ef3-backup-*.sqlite`), and +validates the converted data with the new storage reader before rebuilding the +original in one transaction. Unknown history, unexpected schema changes, or +unreadable data stop the upgrade rather than guessing at a conversion. + +Shared wallet columns and opaque DET metadata are copied and compared byte for byte. +Version-domain aliases retain the largest sequence on a collision. A tombstoned +identity still present in DET's active roster remains live; genuinely retired +typed identity rows remain in the backup, while their opaque local metadata is +preserved. An ambiguous or malformed saved identity roster fails closed. +The encrypted seed vault is not migrated or rewritten. + +Keep the retained backups. Downgrading does not automatically reverse the +database conversion; recovery requires the corresponding backup and the prior +application version. Validation uses synthetic upstream fixtures, not a user's +real profile. + +## Validation and limits + +- `cargo fmt --all`: completed. +- `cargo clippy --all-features --all-targets -- -D warnings`: passed with the + exact CI flags. +- `cargo test --lib --all-features`: 2520 passed, none failed or ignored. + This includes old app-preference and populated wallet upgrades, persisted + balances/identities, backup preservation, interrupted-process rollback, + WAL snapshots, writer exclusion, unknown-schema rejection and repeated open. +- Focused regressions reproduced and fixed stale swept-transaction history and + identity metadata deletion during ownership transfer. The single-UTXO Max-send + regression is included in the normal passing suite. +- Network-dependent backend E2E and GUI tests were not run. The checks used Linux + and synthetic data; they do not establish contact-payment behavior on a live + network or support for hosts with larger operating-system memory pages. + +`cargo audit` reports the same five advisories against both lockfiles: +RUSTSEC-2026-0204 (`crossbeam-epoch`), RUSTSEC-2026-0258 (`h2`), +RUSTSEC-2026-0194 and RUSTSEC-2026-0195 (`quick-xml`), and RUSTSEC-2026-0257 +(`webbrowser`). This upgrade introduces none of those five, but the audit is +not clean. Unmaintained/unsound/yanked warnings also remain. This is a bounded +upgrade review, not a whole-Platform security audit. + +Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent diff --git a/src/backend_task/contract.rs b/src/backend_task/contract.rs index c5a8d0d18..a21cd10bf 100644 --- a/src/backend_task/contract.rs +++ b/src/backend_task/contract.rs @@ -78,6 +78,7 @@ impl AppContext { // Fetch the contract description from the Search Contract let search_contract = &self.keyword_search_contract; let document_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: search_contract.clone(), document_type_name: "fullDescription".to_string(), diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index 6263c5a71..fa9c0418d 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -649,6 +649,7 @@ async fn resolve_username_to_identity( // Use the cached DPNS contract from AppContext instead of fetching from network let domain_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: app_context.dpns_contract.clone(), document_type_name: "domain".to_string(), diff --git a/src/backend_task/document.rs b/src/backend_task/document.rs index 7c52a3ea8..59c8bfef9 100644 --- a/src/backend_task/document.rs +++ b/src/backend_task/document.rs @@ -102,6 +102,7 @@ impl AppContext { document_id: Identifier, ) -> Result { let document_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract, document_type_name: document_type.name().to_string(), diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 2959848db..ddcc65bd6 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -401,7 +401,7 @@ pub enum TaskError { }, /// An identity is still in the wallet store's unowned scope immediately - /// after being withdrawn from it — upstream's tombstone write logs a + /// after being withdrawn from it — upstream's deletion write logs a /// persist failure and reports the removal as done regardless, so the /// readback is the only evidence it landed. The next boot's reconcile /// re-issues the withdrawal, which is what the message offers. Carries the @@ -423,6 +423,13 @@ pub enum TaskError { source: platform_wallet_storage::WalletStorageError, }, + /// A pinned-PR wallet database could not be upgraded without losing data. + #[error(transparent)] + PlatformDatabaseUpgrade { + #[from] + source: crate::wallet_backend::platform_compatibility::UpgradeError, + }, + /// Persisted Core transaction rows could not be read through the upstream /// wallet persistence API during wallet registration. #[error( diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index dc04b5500..5e7e53830 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -394,6 +394,7 @@ impl AppContext { use dash_sdk::platform::{Document, DocumentQuery, FetchMany}; let query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index 5420cd8e3..5bd6b0e8b 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -401,6 +401,7 @@ impl AppContext { // Fetch DPNS names using SDK let dpns_names_document_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), diff --git a/src/backend_task/identity/load_identity_by_dpns_name.rs b/src/backend_task/identity/load_identity_by_dpns_name.rs index 233e7969b..4fda544e2 100644 --- a/src/backend_task/identity/load_identity_by_dpns_name.rs +++ b/src/backend_task/identity/load_identity_by_dpns_name.rs @@ -23,6 +23,7 @@ impl AppContext { // Query the DPNS contract for the domain document let domain_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), @@ -78,6 +79,7 @@ impl AppContext { // Fetch all DPNS names owned by this identity let dpns_names_document_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index d165b5ddc..19e62dc7d 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -100,6 +100,7 @@ impl AppContext { let identity_id = identity.id(); let dpns_names_document_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), diff --git a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs index 3255ad0c5..d977c07b0 100644 --- a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs +++ b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs @@ -22,6 +22,7 @@ impl AppContext { let identity_id = qualified_identity.identity.id(); let dpns_names_document_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index 98557ec4a..a129b6f8b 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -189,6 +189,7 @@ impl AppContext { .map_err(|error| rebrand_dpns_domain_conflict(TaskError::from(error)))?; let dpns_names_document_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: self.dpns_contract.clone(), document_type_name: "domain".to_string(), diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index c71a9731a..ad3d408e0 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -722,6 +722,7 @@ impl AppContext { .map_err(|e| TaskError::from(SdkError::Protocol(e)))?; let queued_document_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: Arc::new(withdrawal_contract), document_type_name: "withdrawal".to_string(), @@ -772,6 +773,7 @@ impl AppContext { .map_err(|e| TaskError::from(SdkError::Protocol(e)))?; let completed_document_query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: Arc::new(withdrawal_contract), document_type_name: "withdrawal".to_string(), @@ -900,6 +902,7 @@ impl AppContext { ]; let query = DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: Arc::new(withdrawal_contract), document_type_name: "withdrawal".to_string(), diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 2999713eb..3375c75e6 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -985,15 +985,8 @@ impl AppContext { Ok(()) } - /// Test-only: write a wallet-less identity's sidecar record WITHOUT the - /// upstream unowned-scope mirror [`Self::insert_local_qualified_identity`] - /// always performs for one. Simulates the genuine pre-#955 on-disk shape - /// — a sidecar record that predates the mirror existing at all — which a - /// [`WalletBackend::remove_unowned_identity`](crate::wallet_backend::WalletBackend::remove_unowned_identity) - /// call cannot: that leaves a *tombstoned* upstream row (an add-then- - /// remove path), not the *absent* row (a row that was never added) an - /// upgrading pre-#955 install actually has, and upstream's upsert may - /// treat reviving a tombstone differently from a first insert. + /// Test-only: store a wallet-less sidecar without its upstream mirror, + /// matching an install that predates unowned-identity registration. #[cfg(test)] pub(crate) fn insert_local_qualified_identity_sidecar_only( &self, @@ -1439,12 +1432,9 @@ impl AppContext { /// from it is gone whatever its blob still says) and then on the record's /// `wallet_hash`. /// - /// The boot reconcile re-checks this before withdrawing an upstream - /// unowned registration: an identity stored after its id scan must keep - /// its registration, while one that has since gained a wallet must still - /// lose it — a distinction [`Self::has_local_qualified_identity`] cannot - /// make. Two wrapper reads, the roster and the record: no blob decode, no - /// vault touch. + /// Boot reconciliation re-checks this before adding an unowned registration, + /// so an identity that gained a wallet after the snapshot is not re-added. + /// Reads the roster and wrapper without decoding the identity or opening keys. pub(crate) fn stored_identity_is_wallet_less( &self, id: &Identifier, @@ -1656,23 +1646,15 @@ impl AppContext { /// children. Returns `Ok(())` even when the identity is unknown — /// mirrors the pre-C7 `DELETE` which silently no-ops on missing rows. /// - /// Cleanup verdict: explicit. DET never issues a row `DELETE` against the - /// upstream `identities` table (that table is owned by the upstream sync - /// layer; DET stores the qualified-identity blob in the `meta_identity` - /// k/v scope only), so the upstream `cascade_meta_on_identity_delete` - /// trigger — which fires on `DELETE`, not `UPDATE` — never reaches this - /// path. This method therefore drains the Identity scope itself — the - /// blob, the top-up history, and every scheduled vote queued for this - /// identity — and removes the Global index entries that the trigger - /// would not touch. For the same reason it clears this identity's DashPay - /// contact overlays and its token-list preferences, which live under - /// Global keys naming the owner and so outlive the scope drain. + /// Explicitly drains the Identity scope, including its blob, top-up history + /// and scheduled votes, before deleting vault keys. It also removes Global + /// index entries, DashPay overlays and token preferences that upstream + /// identity cascades cannot reach. Wallet-owned upstream rows remain intact. /// - /// For a wallet-less identity this also *tombstones* (never row-deletes) - /// its mirrored row in the upstream unowned scope, via + /// For a wallet-less identity this also deletes its upstream mirrored row via /// [`WalletBackend::remove_unowned_identity`](crate::wallet_backend::WalletBackend::remove_unowned_identity) /// below — so upstream stops advertising a node this device no longer - /// has. Best-effort, and retried like registration is: a tombstone lost + /// has. Best-effort, and retried like registration is: a removal lost /// here is re-issued by the next boot's /// `AppContext::reconcile_unowned_identities`, which withdraws every /// unowned registration whose sidecar record is gone. @@ -1953,7 +1935,7 @@ impl AppContext { /// Test-only: remove `identifier` from the Global enumeration index /// without touching the upstream unowned scope or any other /// Identity-scoped data. Simulates a sidecar delete whose upstream - /// tombstone never landed — e.g. a crash between + /// removal never landed — e.g. a crash between /// [`Self::delete_local_qualified_identity`]'s sidecar drain and its /// [`WalletBackend::remove_unowned_identity`](crate::wallet_backend::WalletBackend::remove_unowned_identity) /// call, or the `wallet_backend()` guard above finding no backend wired diff --git a/src/context/mod.rs b/src/context/mod.rs index bfa05302d..20735b7c3 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -625,14 +625,13 @@ impl AppContext { /// `/det-app.sqlite`. Used by every `AppContext::new` /// callsite — pass a single `Arc` to all per-network /// contexts so they share the same blob. - pub fn open_app_kv( - data_dir: &std::path::Path, - ) -> Result, platform_wallet_storage::WalletStorageError> { - use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; - crate::app_dir::ensure_data_dir_exists(data_dir)?; + pub fn open_app_kv(data_dir: &std::path::Path) -> Result, TaskError> { + use platform_wallet_storage::SqlitePersisterConfig; + crate::app_dir::ensure_data_dir_exists(data_dir) + .map_err(|source| TaskError::FileSystem { source })?; let path = data_dir.join("det-app.sqlite"); let config = SqlitePersisterConfig::new(path); - let persister = Arc::new(SqlitePersister::open(config)?); + let persister = Arc::new(crate::wallet_backend::platform_compatibility::open(config)?); Ok(Arc::new(DetKv::new(persister))) } @@ -1628,6 +1627,39 @@ pub(crate) const fn default_platform_version(_network: &Network) -> &'static Pla mod tests { use super::*; + #[test] + fn platform_compatibility_open_app_kv_preserves_pr_pin_preferences() { + use crate::wallet_backend::DetScope; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("det-app.sqlite"); + let db = rusqlite::Connection::open(&path).unwrap(); + db.execute_batch(include_str!( + "../wallet_backend/platform_compatibility/fixtures/67d4ef3.sql" + )) + .unwrap(); + let preference = "saved preference".to_owned(); + let mut encoded = vec![1]; + encoded.extend( + bincode::serde::encode_to_vec(&preference, bincode::config::standard()).unwrap(), + ); + db.execute( + "INSERT INTO meta_global (key, value) VALUES (?1, ?2)", + rusqlite::params!["det:test:preference", encoded], + ) + .unwrap(); + drop(db); + + for _ in 0..2 { + let kv = AppContext::open_app_kv(dir.path()).unwrap(); + assert_eq!( + kv.get::(DetScope::Global, "det:test:preference") + .unwrap(), + Some(preference.clone()) + ); + } + } + #[test] fn epoch_workaround_uses_v12_for_every_network() { for network in [ diff --git a/src/context/wallet_lifecycle/bootstrap.rs b/src/context/wallet_lifecycle/bootstrap.rs index f016e754b..4510371a8 100644 --- a/src/context/wallet_lifecycle/bootstrap.rs +++ b/src/context/wallet_lifecycle/bootstrap.rs @@ -281,7 +281,7 @@ impl AppContext { /// identity upstream doesn't have yet (backfilling nodes stored before /// that registration existed, and retrying any whose write-through /// failed), and withdraws every upstream registration whose sidecar - /// record is gone — a tombstone lost to a crash or storage error between + /// record is gone — a removal lost to a crash or storage error between /// [`AppContext::delete_local_qualified_identity`](crate::context::AppContext::delete_local_qualified_identity)'s /// synchronous attempt and its upstream write. Masternode/evonode nodes /// are the expected case, but any wallet-less identity DET stores takes @@ -304,8 +304,9 @@ impl AppContext { /// Neither snapshot is taken atomically with the other, so an identity /// written between them reads as the opposite of what it is — a stale /// registration, or a wallet-less identity that has since gained a wallet. - /// Both loops therefore re-check every candidate against the sidecar under - /// that identity's record guard instead of trusting the snapshot. + /// Both loops re-check candidates under their identity record guard. + /// Listed identities retain their parent row until per-wallet reconciliation + /// promotes it; deleting the unowned row would cascade through DET metadata. pub(super) fn reconcile_unowned_identities(&self, backend: &WalletBackend) { self.reconcile_unowned_identities_seamed(backend, || {}); } @@ -398,16 +399,13 @@ impl AppContext { let mut removed = 0usize; for id in registered.difference(&wallet_less) { - // Re-read under the guard `insert_local_qualified_identity` holds - // across both of its writes: a record stored since the id scan - // keeps its registration, while one that has since gained a wallet - // still loses it. A read failure keeps the registration — a stale - // row costs one more boot, a wrongly withdrawn one costs the node. + // A listed identity still needs its parent row, including while + // its wallet ownership awaits durable promotion. let lock = self.identity_record_lock(*id); let _record_guard = lock .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - match self.stored_identity_is_wallet_less(id) { + match self.is_identity_listed(id) { Ok(true) => continue, Ok(false) => {} Err(error) => { diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 978326abc..fb4e3e73a 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -4987,18 +4987,18 @@ async fn reconcile_does_not_withdraw_an_identity_inserted_between_its_two_snapsh backend.shutdown().await; } -/// A wallet-OWNED sidecar record must not shield a stale unowned -/// registration: only a wallet-LESS record means "upstream should still -/// advertise this identity". Guarding on mere existence would strand the -/// registration of an identity that has since gained a wallet. +/// Ownership changes preserve identity metadata until the wallet's ordinary +/// reconciliation durably promotes the unowned parent row. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn reconcile_withdraws_a_stale_registration_whose_sidecar_is_wallet_owned() { - let (ctx, sender, _tmp) = offline_testnet_context(); +async fn reconcile_preserves_wallet_owned_identity_until_durable_promotion() { + use crate::wallet_backend::DetScope; + + let (ctx, sender, tmp) = offline_testnet_context(); ctx.ensure_wallet_backend(sender) .await .expect("ensure_wallet_backend should succeed offline"); let backend = ctx.wallet_backend().expect("backend wired"); - let (seed_hash, _) = register_backend_only_test_wallet(&backend, [0x58u8; 64]).await; + let (seed_hash, wallet_id) = register_backend_only_test_wallet(&backend, [0x58u8; 64]).await; // Registered unowned by an earlier boot, then stored as wallet-owned: the // registration is stale from that moment on. @@ -5009,16 +5009,104 @@ async fn reconcile_withdraws_a_stale_registration_whose_sidecar_is_wallet_owned( .expect("register unowned"); ctx.insert_local_qualified_identity(&owned, &Some((seed_hash, 0))) .expect("insert wallet-owned identity"); + let id_bytes = owned_id.to_buffer(); + backend + .kv() + .put( + DetScope::Identity(&id_bytes), + "test:identity-note", + &"keep this note", + ) + .expect("store identity metadata"); ctx.reconcile_unowned_identities(&backend); + assert_eq!( + ctx.stored_identity_wallet_link(&owned_id) + .expect("read identity ownership after reconciliation"), + Some((seed_hash, 0)), + "reconciling the unowned mirror must preserve the live identity sidecar" + ); + assert!( + backend + .unowned_identity_ids() + .expect("read unowned identities before promotion") + .contains(&owned_id), + "the parent row must remain until wallet reconciliation promotes it" + ); + + ctx.reconcile_managed_identities(&backend, &seed_hash).await; + ctx.reconcile_unowned_identities(&backend); + assert!( !backend .unowned_identity_ids() .expect("read unowned identities") .contains(&owned_id), - "a registration whose sidecar is wallet-owned must still be withdrawn" + "durable promotion must remove the identity from the unowned scope" + ); + let conn = rusqlite::Connection::open_with_flags( + wallet_database_path(tmp.path(), Network::Testnet), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .expect("open persisted identity store"); + let persisted_owner: (Vec, u32) = conn + .query_row( + "SELECT wallet_id, identity_index FROM identities WHERE identity_id = ?1", + [&id_bytes[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("read promoted identity ownership"); + assert_eq!(persisted_owner, (wallet_id.to_vec(), 0)); + assert_eq!( + ctx.stored_identity_wallet_link(&owned_id) + .expect("read identity ownership after promotion"), + Some((seed_hash, 0)) ); + assert_eq!( + backend + .kv() + .get::(DetScope::Identity(&id_bytes), "test:identity-note") + .expect("read identity metadata after promotion"), + Some("keep this note".to_owned()) + ); + + backend.shutdown().await; +} + +/// An unavailable owning wallet defers promotion without deleting the identity. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reconcile_preserves_wallet_owned_identity_when_promotion_is_unavailable() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure wallet backend"); + let backend = ctx.wallet_backend().expect("backend wired"); + let seed_hash = [0x59; 32]; + let owned = wallet_owned_qualified_identity(Some(0)); + let owned_id = identity_id_of(&owned); + backend + .ensure_identity_unowned(&owned.identity) + .expect("register unowned"); + ctx.insert_local_qualified_identity(&owned, &Some((seed_hash, 0))) + .expect("store identity whose wallet is unavailable"); + assert!(backend.registered_wallet_id(&seed_hash).is_none()); + + for _ in 0..2 { + ctx.reconcile_unowned_identities(&backend); + ctx.reconcile_managed_identities(&backend, &seed_hash).await; + assert_eq!( + ctx.stored_identity_wallet_link(&owned_id) + .expect("read retained identity ownership"), + Some((seed_hash, 0)) + ); + assert!( + backend + .unowned_identity_ids() + .expect("read retained parent row") + .contains(&owned_id) + ); + } backend.shutdown().await; } diff --git a/src/ui/tokens/view_token_claims_screen.rs b/src/ui/tokens/view_token_claims_screen.rs index edfc7c9a2..318952918 100644 --- a/src/ui/tokens/view_token_claims_screen.rs +++ b/src/ui/tokens/view_token_claims_screen.rs @@ -57,6 +57,7 @@ impl ViewTokenClaimsScreen { Self { identity_token_basic_info: identity_token_basic_info.clone(), new_claims_query: DocumentQuery { + sub_queries: Vec::new(), select: SelectProjection::documents(), data_contract: app_context.token_history_contract.clone(), document_type_name: "claim".to_string(), diff --git a/src/wallet_backend/dashpay.rs b/src/wallet_backend/dashpay.rs index 3be54dc19..dbb58b609 100644 --- a/src/wallet_backend/dashpay.rs +++ b/src/wallet_backend/dashpay.rs @@ -924,7 +924,10 @@ impl WalletBackend { managed .record_dashpay_payment(tx_id, entry, &persister) .map_err(|e| TaskError::WalletBackend { - source: Arc::new(e.into()), + source: Arc::new(platform_wallet::PlatformWalletError::from_store_failure( + self.inner.wallet_persister.as_ref(), + e, + )), })?; Ok(()) } diff --git a/src/wallet_backend/event_bridge.rs b/src/wallet_backend/event_bridge.rs index a119c58ec..82a3c90df 100644 --- a/src/wallet_backend/event_bridge.rs +++ b/src/wallet_backend/event_bridge.rs @@ -286,6 +286,12 @@ impl EventHandler for EventBridge { *wallet_id } WalletEvent::SyncHeightAdvanced { wallet_id, .. } => *wallet_id, + WalletEvent::TransactionsSwept { + wallet_id, txids, .. + } => { + self.snapshots.remove_transactions(wallet_id, txids); + *wallet_id + } WalletEvent::ChainLockProcessed { wallet_id, .. } => { // Upstream chain-lock notification: no transaction deltas to // accumulate, but balances may shift from unconfirmed to @@ -993,6 +999,33 @@ mod tests { assert!(drained_repaint(&mut rx)); } + #[test] + fn swept_transactions_leave_history_without_removing_other_wallets() { + let (bridge, _cs, mut rx) = make_bridge(); + let swept = received_record(&funding_address(), 100); + let retained = received_record(&funding_address(), 200); + let txid = swept.txid; + bridge + .snapshots + .accumulate_transactions(&[9; 32], [&swept, &retained]); + bridge.snapshots.accumulate_transactions(&[8; 32], [&swept]); + + bridge.on_wallet_event(&WalletEvent::TransactionsSwept { + wallet_id: [9; 32], + txids: vec![txid], + superseded_by: retained.txid, + winner_mined_height: None, + released_outpoints: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }); + + assert_eq!(bridge.snapshots.transaction_status(&[9; 32], &txid), None); + assert_eq!(bridge.snapshots.transaction_count(&[9; 32]), 1); + assert_eq!(bridge.snapshots.transaction_count(&[8; 32]), 1); + assert!(drained_repaint(&mut rx)); + } + #[test] fn live_event_for_hydrated_txid_upserts_without_duplicate() { let (bridge, _cs, _rx) = make_bridge(); diff --git a/src/wallet_backend/identity_ops.rs b/src/wallet_backend/identity_ops.rs index 103b27f6c..3f0e06132 100644 --- a/src/wallet_backend/identity_ops.rs +++ b/src/wallet_backend/identity_ops.rs @@ -518,15 +518,15 @@ impl WalletBackend { /// its removal from DET. A no-op for an identity that is not registered /// there — including every wallet-owned one. /// - /// Upstream records this as a tombstone rather than a row delete, so it - /// cannot reach the identity's `meta_identity` rows. + /// Upstream deletes the parent and its identity-scoped metadata. Callers + /// must preserve this row while a listed DET identity still depends on it. /// /// Retried like registration is: the caller /// ([`AppContext::delete_local_qualified_identity`](crate::context::AppContext::delete_local_qualified_identity)) /// only logs a failure, but the boot reconcile /// (`AppContext::reconcile_unowned_identities`) works both directions and /// re-issues this call for every registration whose sidecar record is - /// gone. A tombstone lost to a crash or storage error between the sidecar + /// gone. A removal lost to a crash or storage error between the sidecar /// delete and this call therefore costs one boot, not the record. /// /// An `Ok` is a *verified* one: the row is read back as gone before the @@ -535,7 +535,7 @@ impl WalletBackend { /// # Errors /// [`TaskError::UnownedIdentityMirrorRemains`] when the row is still in /// the unowned scope right after being withdrawn from it. Upstream's - /// `remove_identity` persists the tombstone through `persist_removal`, + /// `remove_identity` persists the deletion through `persist_removal`, /// which swallows a persister failure into `tracing::error!` and returns /// `()`, so the removal reports success either way (`manager/lifecycle.rs` /// at pin `4784de03`) — the readback is the only evidence available. Paid @@ -608,6 +608,7 @@ impl WalletBackend { Ok(platform_wallet::changeset::IdentityManagerStartState { out_of_wallet_identities: self.load_unowned_identities()?, wallet_identities: Default::default(), + scan_states: Default::default(), } .into()) } diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index f11fa6aee..17e5aacd2 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -49,6 +49,7 @@ mod loader; mod payments; #[cfg(test)] pub(crate) mod persist_fault_test_support; +pub mod platform_compatibility; pub(crate) mod poison; pub mod secret_access; pub mod secret_prompt; @@ -417,7 +418,7 @@ struct Inner { #[cfg(test)] swallow_next_unowned_write: std::sync::atomic::AtomicBool, /// Drops the next unowned-scope removal, reproducing upstream's swallowed - /// tombstone persist — it logs and returns the removed identity anyway — + /// deletion persist — it logs and returns the removed identity anyway — /// which leaves the withdrawn row on disk. #[cfg(test)] swallow_next_unowned_removal: std::sync::atomic::AtomicBool, @@ -558,10 +559,7 @@ impl WalletBackend { let wallet_database_path = wallet_database_path(ctx.data_dir(), network); let persister_config = SqlitePersisterConfig::new(wallet_database_path.clone()); - let persister = Arc::new( - SqlitePersister::open(persister_config) - .map_err(TaskError::from_wallet_storage_open_error)?, - ); + let persister = Arc::new(platform_compatibility::open(persister_config)?); // Reuse the vault handle `AppContext` already opened at boot. The file // backend holds an exclusive advisory lock for the handle's lifetime, // so opening a second handle here would fail with `AlreadyLocked` — and @@ -2585,7 +2583,10 @@ impl WalletBackend { } }; recorded.map_err(|e| TaskError::WalletBackend { - source: Arc::new(e.into()), + source: Arc::new(platform_wallet::PlatformWalletError::from_store_failure( + self.inner.wallet_persister.as_ref(), + e, + )), })?; } None => match direction { @@ -3137,7 +3138,13 @@ fn map_shielded_op_error(e: platform_wallet::error::PlatformWalletError) -> Task // Every remaining variant → generic WalletBackend wrapper. other @ (P::WalletCreation(_) - | P::PlatformNodePool(_) + | P::StaleReservation + | P::InputMidBroadcast { .. } + | P::AssetLockInputConflict { .. } + | P::AssetLockInputContested { .. } + | P::MasternodeListUnavailable + | P::SeedBindingUnanswered { .. } + | P::ContactSyncUnreachable { .. } | P::PersisterLoad(_) | P::AddressNonceMismatch { .. } | P::WalletNotFound(_) @@ -3437,7 +3444,13 @@ fn identity_op_error_kind(e: &platform_wallet::error::PlatformWalletError) -> Id // Everything else — preconditions, wallet state, builder errors. P::WalletCreation(_) - | P::PlatformNodePool(_) + | P::StaleReservation + | P::InputMidBroadcast { .. } + | P::AssetLockInputConflict { .. } + | P::AssetLockInputContested { .. } + | P::MasternodeListUnavailable + | P::SeedBindingUnanswered { .. } + | P::ContactSyncUnreachable { .. } | P::WalletNotFound(_) | P::WalletAlreadyExists(_) | P::IdentityAlreadyExists(_) diff --git a/src/wallet_backend/payments.rs b/src/wallet_backend/payments.rs index c765cbcd9..f9037ba9f 100644 --- a/src/wallet_backend/payments.rs +++ b/src/wallet_backend/payments.rs @@ -1018,22 +1018,9 @@ mod tests { use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - /// Reproduces . - /// The root cause is upstream in `dashpay/rust-dashcore` key-wallet's - /// `coin_selection.rs`, pinned at revision `be6e776`, tracked at - /// . - /// - /// Asserts the *correct* behavior (a Max send folds the zero/dust remainder - /// into its fee), so it stays RED until the upstream fix lands. `#[ignore]` - /// keeps it out of the CI gate meanwhile; run it manually with: - /// - /// ```sh - /// cargo test --lib -- core_max_send_with_single_utxo_builds_without_change --ignored - /// ``` - /// - /// Remove `#[ignore]` once the pinned key-wallet revision contains the fix. + /// A Max send folds its zero/dust remainder into the fee (DET #909, + /// rust-dashcore #911). #[test] - #[ignore = "RED until upstream key-wallet coin-selection fix lands (rust-dashcore#911); run with --ignored"] fn core_max_send_with_single_utxo_builds_without_change() { const BALANCE_DUFFS: u64 = 10_000_000; diff --git a/src/wallet_backend/platform_compatibility/engine.rs b/src/wallet_backend/platform_compatibility/engine.rs new file mode 100644 index 000000000..6c8b3f9e1 --- /dev/null +++ b/src/wallet_backend/platform_compatibility/engine.rs @@ -0,0 +1,432 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior}; + +const OLD_SCHEMA: &str = include_str!("fixtures/67d4ef3.sql"); +const TARGET_SCHEMA: &str = include_str!("fixtures/e3cd7cf.sql"); +const HISTORY: &str = "refinery_schema_history"; +const IDENTITY_INDEX_KEY: &str = "det:identity_index:v1"; + +/// A compatibility upgrade stopped before committing changes to the original database. +#[derive(Debug, thiserror::Error)] +pub enum UpgradeError { + #[error( + "Could not upgrade wallet data. Check available disk space and restart the application." + )] + Sqlite(#[from] rusqlite::Error), + #[error( + "Could not back up wallet data. Check available disk space and restart the application." + )] + Io(#[from] std::io::Error), + #[error( + "Wallet data does not match a supported upgrade. Keep your data folder and reopen the previous application version." + )] + Unrecognized, + #[error( + "The saved identity list could not be read. Keep your data folder and reopen the previous application version." + )] + IdentityRoster, + #[error( + "The saved identity list could not be read. Keep your data folder and reopen the previous application version." + )] + IdentityRosterDecode(#[from] bincode::error::DecodeError), + #[error( + "Wallet data verification failed. Keep your data folder and reopen the previous application version." + )] + Verification, + #[error( + "The updated application could not read your wallet data. Keep your data folder and reopen the previous application version." + )] + TypedValidation(#[source] Box), +} + +#[derive(Debug, PartialEq, Eq)] +struct Object { + kind: String, + name: String, + sql: String, +} + +fn objects(conn: &Connection) -> Result, UpgradeError> { + Ok(conn + .prepare( + "SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name", + )? + .query_map([], |r| { + Ok(Object { + kind: r.get(0)?, + name: r.get(1)?, + sql: r.get(2)?, + }) + })? + .collect::>()?) +} + +fn history(conn: &Connection) -> Result, UpgradeError> { + Ok(conn + .prepare("SELECT version, name, checksum FROM refinery_schema_history ORDER BY version")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .collect::>()?) +} + +fn quote(identifier: &str) -> String { + format!("\"{}\"", identifier.replace('"', "\"\"")) +} + +fn columns(conn: &Connection, table: &str) -> Result, UpgradeError> { + Ok(conn + .prepare(&format!("PRAGMA table_info({})", quote(table)))? + .query_map([], |r| r.get(1))? + .collect::>()?) +} + +fn verify(conn: &Connection) -> Result<(), UpgradeError> { + let result: String = conn.query_row("PRAGMA integrity_check", [], |r| r.get(0))?; + if result != "ok" || conn.prepare("PRAGMA foreign_key_check")?.exists([])? { + return Err(UpgradeError::Verification); + } + Ok(()) +} + +fn old_reference() -> Result { + let conn = Connection::open_in_memory()?; + conn.execute_batch(OLD_SCHEMA)?; + Ok(conn) +} + +fn target_reference() -> Result { + let conn = Connection::open_in_memory()?; + conn.execute_batch(TARGET_SCHEMA)?; + Ok(conn) +} + +fn active_identities(conn: &Connection) -> Result>, UpgradeError> { + let value: Option> = conn + .query_row( + "SELECT value FROM meta_global WHERE key = ?1", + [IDENTITY_INDEX_KEY], + |r| r.get(0), + ) + .optional()?; + let Some(value) = value else { + if conn.prepare("SELECT 1 FROM identities i JOIN meta_identity m ON i.identity_id = m.identity_id WHERE i.tombstoned = 1 AND m.key = 'det:identity:v1'")?.exists([])? { + return Err(UpgradeError::IdentityRoster); + } + return Ok(BTreeSet::new()); + }; + let Some((&1, body)) = value.split_first() else { + return Err(UpgradeError::IdentityRoster); + }; + let (ids, consumed): (Vec<[u8; 32]>, usize) = bincode::serde::decode_from_slice( + body, + bincode::config::standard().with_limit::<16777216>(), + )?; + if consumed != body.len() { + return Err(UpgradeError::IdentityRoster); + } + Ok(ids.into_iter().map(Vec::from).collect()) +} + +fn retired_identities(conn: &Connection) -> Result>, UpgradeError> { + let active = active_identities(conn)?; + Ok(conn + .prepare("SELECT identity_id FROM identities WHERE tombstoned = 1")? + .query_map([], |r| r.get::<_, Vec>(0))? + .collect::, _>>()? + .into_iter() + .filter(|id| !active.contains(id)) + .collect()) +} + +fn retirement_column(table: &str) -> Option<&'static str> { + match table { + "identities" + | "identity_keys" + | "token_balances" + | "dashpay_profiles" + | "dashpay_payments_overlay" => Some("identity_id"), + "contacts" | "ignored_senders" => Some("owner_id"), + "pending_contact_crypto" => Some("owner_identity_id"), + _ => None, + } +} + +fn selected_rows(table: &str) -> String { + retirement_column(table) + .map(|c| { + format!( + " WHERE {} NOT IN (SELECT id FROM temp.retired_identities)", + quote(c) + ) + }) + .unwrap_or_default() +} + +fn backup(path: &Path) -> Result { + let parent = path.parent().ok_or(UpgradeError::Unrecognized)?; + let filename = path.file_name().ok_or(UpgradeError::Unrecognized)?; + let prefix = format!("{}.platform-67d4ef3-backup-", filename.to_string_lossy()); + let file = tempfile::Builder::new() + .prefix(&prefix) + .suffix(".sqlite") + .tempfile_in(parent)?; + let source = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NOFOLLOW, + )?; + let mut dest = Connection::open(file.path())?; + // A separate reader sees the committed snapshot while the writer lock excludes changes. + let copier = rusqlite::backup::Backup::new(&source, &mut dest)?; + if !matches!(copier.step(-1)?, rusqlite::backup::StepResult::Done) { + return Err(UpgradeError::Verification); + } + drop(copier); + verify(&dest)?; + drop(dest); + file.as_file().sync_all()?; + let (_, path) = file.keep().map_err(|e| e.error)?; + #[cfg(unix)] + std::fs::File::open(parent)?.sync_all()?; + Ok(path) +} + +fn copy_rows( + source: &Connection, + target: &Connection, + table: &str, + cols: &[String], + filter: &str, +) -> Result<(), UpgradeError> { + let names = cols.iter().map(|c| quote(c)).collect::>().join(","); + let placeholders = vec!["?"; cols.len()].join(","); + let mut input = source.prepare(&format!("SELECT {names} FROM {}{filter}", quote(table)))?; + let mut output = target.prepare(&format!( + "INSERT INTO {} ({names}) VALUES ({placeholders})", + quote(table) + ))?; + let mut rows = input.query([])?; + while let Some(row) = rows.next()? { + let values = (0..cols.len()) + .map(|i| row.get::<_, rusqlite::types::Value>(i)) + .collect::, _>>()?; + output.execute(rusqlite::params_from_iter(values))?; + } + Ok(()) +} + +fn equal_rows( + source: &Connection, + target: &Connection, + table: &str, + cols: &[String], + filter: &str, +) -> Result<(), UpgradeError> { + let names = cols.iter().map(|c| quote(c)).collect::>().join(","); + let sql = format!("SELECT {names} FROM {}", quote(table)); + let order = format!(" ORDER BY {names}"); + let mut a = source.prepare(&format!("{sql}{filter}{order}"))?; + let mut b = target.prepare(&format!("{sql}{order}"))?; + let mut a = a.query([])?; + let mut b = b.query([])?; + loop { + match (a.next()?, b.next()?) { + (None, None) => return Ok(()), + (Some(a), Some(b)) => { + for i in 0..cols.len() { + if a.get_ref(i)? != b.get_ref(i)? { + return Err(UpgradeError::Verification); + } + } + } + _ => return Err(UpgradeError::Verification), + } + } +} + +fn allowed_columns( + table: &str, + old: &[String], + new: &[String], +) -> Result, UpgradeError> { + let old_set = old.iter().map(String::as_str).collect::>(); + let new_set = new.iter().map(String::as_str).collect::>(); + let added = new_set.difference(&old_set).copied().collect::>(); + let removed = old_set.difference(&new_set).copied().collect::>(); + let expected_added: &[&str] = match table { + "core_utxos" => &[ + "is_sweep_placeholder", + "spent_in_txid", + "winner_mined_height", + ], + "core_sync_state" => &["chainlock_height"], + _ => &[], + }; + let expected_removed: &[&str] = if table == "identities" { + &["tombstoned"] + } else { + &[] + }; + if added != expected_added || removed != expected_removed { + return Err(UpgradeError::Unrecognized); + } + Ok(old + .iter() + .filter(|c| new_set.contains(c.as_str())) + .cloned() + .collect()) +} + +/// Translate only the exact pinned PR schema, preserving the original in a durable backup. +pub(super) fn upgrade( + path: &Path, + target_path: &Path, + validate: impl FnOnce(&Path) -> Result<(), UpgradeError>, +) -> Result, UpgradeError> { + upgrade_with_hook(path, target_path, validate, || Ok(())) +} + +fn upgrade_with_hook( + path: &Path, + target_path: &Path, + validate: impl FnOnce(&Path) -> Result<(), UpgradeError>, + before_commit: impl FnOnce() -> Result<(), UpgradeError>, +) -> Result, UpgradeError> { + let old = old_reference()?; + let reference = target_reference()?; + let mut source = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NOFOLLOW, + )?; + source.busy_timeout(std::time::Duration::from_secs(5))?; + source.pragma_update(None, "foreign_keys", false)?; + source.pragma_update(None, "synchronous", "FULL")?; + let source = source.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = objects(&source)?; + if !existing.iter().any(|o| o.name == HISTORY) { + return Ok(None); + } + if history(&source)? != history(&old)? { + return Ok(None); + } + if existing != objects(&old)? + || source.query_row("PRAGMA application_id", [], |r| r.get::<_, i64>(0))? != 0x504c5754 + { + return Err(UpgradeError::Unrecognized); + } + verify(&source)?; + let retired = retired_identities(&source)?; + let backup = backup(path)?; + let mut target = Connection::open_with_flags( + target_path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NOFOLLOW, + )?; + if objects(&target)? != objects(&reference)? || history(&target)? != history(&reference)? { + return Err(UpgradeError::Unrecognized); + } + let target_objects = objects(&target)?; + target.pragma_update(None, "foreign_keys", false)?; + let target_tx = target.transaction()?; + source.execute_batch("CREATE TEMP TABLE retired_identities (id BLOB PRIMARY KEY)")?; + for id in retired { + source.execute("INSERT INTO temp.retired_identities VALUES (?1)", [id])?; + } + for o in target_objects.iter().filter(|o| o.kind == "trigger") { + target_tx.execute_batch(&format!("DROP TRIGGER {}", quote(&o.name)))?; + } + if target_tx.query_row( + "SELECT count(*) FROM meta_store_generation WHERE id = 0 AND length(generation) = 16", + [], + |r| r.get::<_, i64>(0), + )? != 1 + { + return Err(UpgradeError::Unrecognized); + } + target_tx.execute("DELETE FROM meta_store_generation", [])?; + let old_tables = existing + .iter() + .filter(|o| o.kind == "table") + .map(|o| o.name.as_str()) + .collect::>(); + let new_tables = target_objects + .iter() + .filter(|o| o.kind == "table") + .map(|o| o.name.as_str()) + .collect::>(); + let added = new_tables + .difference(&old_tables) + .copied() + .collect::>(); + if !old_tables.is_subset(&new_tables) + || added != ["identity_scan_failed_indices", "identity_scan_states"] + { + return Err(UpgradeError::Unrecognized); + } + for table in new_tables.iter().filter(|t| **t != HISTORY) { + if target_tx.query_row(&format!("SELECT count(*) FROM {}", quote(table)), [], |r| { + r.get::<_, i64>(0) + })? != 0 + { + return Err(UpgradeError::Unrecognized); + } + } + for table in old_tables.iter().filter(|t| **t != HISTORY) { + let cols = allowed_columns( + table, + &columns(&source, table)?, + &columns(&target_tx, table)?, + )?; + let filter = selected_rows(table); + copy_rows(&source, &target_tx, table, &cols, &filter)?; + equal_rows(&source, &target_tx, table, &cols, &filter)?; + } + for (old_domain, new_domain) in [ + ("wallet_metadata", "wallets"), + ("account_address_pools", "core_address_pool"), + ] { + target_tx.execute( + "INSERT INTO meta_data_versions (wallet_id, domain, seq) SELECT wallet_id, ?2, seq FROM meta_data_versions WHERE domain = ?1 ON CONFLICT(wallet_id, domain) DO UPDATE SET seq = MAX(seq, excluded.seq)", + [old_domain, new_domain], + )?; + } + for o in target_objects.iter().filter(|o| o.kind == "trigger") { + target_tx.execute_batch(&o.sql)?; + } + verify(&target_tx)?; + target_tx.commit()?; + drop(target); + validate(target_path)?; + let target = Connection::open_with_flags( + target_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NOFOLLOW, + )?; + // Rebuild under one SQLite transaction; power loss exposes either complete schema. + for o in existing.iter().filter(|o| o.kind == "trigger") { + source.execute_batch(&format!("DROP TRIGGER {}", quote(&o.name)))?; + } + for table in &old_tables { + source.execute_batch(&format!("DROP TABLE {}", quote(table)))?; + } + for o in target_objects.iter().filter(|o| o.kind == "table") { + source.execute_batch(&o.sql)?; + } + for table in &new_tables { + let cols = columns(&target, table)?; + copy_rows(&target, &source, table, &cols, "")?; + equal_rows(&target, &source, table, &cols, "")?; + } + for o in target_objects.iter().filter(|o| o.kind != "table") { + source.execute_batch(&o.sql)?; + } + if objects(&source)? != target_objects { + return Err(UpgradeError::Verification); + } + verify(&source)?; + before_commit()?; + source.commit()?; + Ok(Some(backup)) +} + +#[cfg(test)] +#[path = "engine/tests.rs"] +mod tests; diff --git a/src/wallet_backend/platform_compatibility/engine/tests.rs b/src/wallet_backend/platform_compatibility/engine/tests.rs new file mode 100644 index 000000000..fb99701a6 --- /dev/null +++ b/src/wallet_backend/platform_compatibility/engine/tests.rs @@ -0,0 +1,332 @@ +use super::*; + +fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wallet.sqlite"); + Connection::open(&path) + .unwrap() + .execute_batch(OLD_SCHEMA) + .unwrap(); + let target = dir.path().join("target.sqlite"); + Connection::open(&target) + .unwrap() + .execute_batch(TARGET_SCHEMA) + .unwrap(); + (dir, path, target) +} + +#[test] +fn platform_compatibility_upgrades_old_empty_database() { + let (_dir, path, target) = fixture(); + let backup = upgrade(&path, &target, |_| Ok(())).unwrap().unwrap(); + assert_eq!( + history(&Connection::open(&backup).unwrap()).unwrap(), + history(&old_reference().unwrap()).unwrap() + ); + assert_eq!( + objects(&Connection::open(&path).unwrap()).unwrap(), + objects(&target_reference().unwrap()).unwrap() + ); + assert!( + upgrade(&path, &target, |_| panic!("Already current")) + .unwrap() + .is_none() + ); +} + +fn snapshot(path: &Path) -> String { + let conn = Connection::open(path).unwrap(); + let mut text = format!("{:?}", objects(&conn).unwrap()); + for o in objects(&conn).unwrap().iter().filter(|o| o.kind == "table") { + let cols = columns(&conn, &o.name).unwrap(); + let order = cols.iter().map(|c| quote(c)).collect::>().join(","); + let mut stmt = conn + .prepare(&format!( + "SELECT * FROM {} ORDER BY {order}", + quote(&o.name) + )) + .unwrap(); + for row in stmt + .query_map([], |r| { + (0..cols.len()) + .map(|i| r.get::<_, rusqlite::types::Value>(i)) + .collect::, _>>() + }) + .unwrap() + { + text.push_str(&format!("{:?}", row.unwrap())); + } + } + text +} + +fn backup_files(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| { + p.file_name() + .unwrap() + .to_string_lossy() + .contains(".platform-67d4ef3-backup-") + }) + .collect() +} + +#[test] +fn platform_compatibility_preserves_active_tombstones_and_all_local_metadata() { + let (dir, path, target) = fixture(); + let conn = Connection::open(&path).unwrap(); + conn.execute( + "INSERT INTO wallets VALUES (zeroblob(32), 'testnet', 0)", + [], + ) + .unwrap(); + let ids = [[1_u8; 32], [2_u8; 32], [3_u8; 32]]; + for (i, id) in ids.iter().enumerate() { + conn.execute( + "INSERT INTO identities VALUES (?1, NULL, NULL, ?2, ?3)", + rusqlite::params![id.as_slice(), &[0_u8, 255, i as u8], i != 2], + ) + .unwrap(); + conn.execute( + "INSERT INTO meta_identity VALUES (?1, 'det:identity:v1', ?2, 31)", + rusqlite::params![id.as_slice(), &[0_u8, 255, i as u8]], + ) + .unwrap(); + conn.execute( + "INSERT INTO meta_token VALUES (?1, ?2, 'det:token:v1', X'00ff80', 32)", + rusqlite::params![id.as_slice(), [5_u8; 32].as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO ignored_senders VALUES (zeroblob(32), ?1, zeroblob(32), 30)", + [id.as_slice()], + ) + .unwrap(); + } + let mut index = vec![1]; + index.extend(bincode::serde::encode_to_vec(vec![ids[0]], bincode::config::standard()).unwrap()); + conn.execute( + "INSERT INTO meta_global VALUES (?1, ?2, 33)", + rusqlite::params![IDENTITY_INDEX_KEY, index], + ) + .unwrap(); + let before = snapshot(&path); + let backup = upgrade(&path, &target, |_| Ok(())).unwrap().unwrap(); + assert_eq!(snapshot(&backup), before); + let migrated = Connection::open(&path).unwrap(); + let found = migrated + .prepare("SELECT identity_id FROM identities ORDER BY identity_id") + .unwrap() + .query_map([], |r| r.get::<_, Vec>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(found, vec![ids[0].to_vec(), ids[2].to_vec()]); + for table in [ + "meta_global", + "meta_identity", + "meta_token", + "meta_store_generation", + ] { + equal_rows( + &Connection::open(&backup).unwrap(), + &migrated, + table, + &columns(&migrated, table).unwrap(), + "", + ) + .unwrap(); + } + assert_eq!( + migrated + .query_row("SELECT count(*) FROM ignored_senders", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 2 + ); + assert!( + upgrade(&path, &target, |_| panic!("Already upgraded")) + .unwrap() + .is_none() + ); + assert_eq!(backup_files(dir.path()).len(), 1); +} + +#[test] +fn platform_compatibility_maps_version_domains_with_maximum_collision() { + let (_dir, path, target) = fixture(); + Connection::open(&path).unwrap().execute_batch("INSERT INTO meta_data_versions VALUES (X'01', 'wallet_metadata', 17), (X'01', 'wallets', 3), (X'01', 'account_address_pools', 9), (X'02', 'wallet_metadata', 4), (X'02', 'wallets', 30);").unwrap(); + upgrade(&path, &target, |_| Ok(())).unwrap(); + let conn = Connection::open(&path).unwrap(); + for (id, domain, seq) in [ + (1, "wallets", 17), + (1, "core_address_pool", 9), + (2, "wallets", 30), + (1, "wallet_metadata", 17), + (1, "account_address_pools", 9), + ] { + assert_eq!( + conn.query_row( + "SELECT seq FROM meta_data_versions WHERE wallet_id = ?1 AND domain = ?2", + rusqlite::params![vec![id as u8], domain], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + seq + ); + } +} + +#[test] +fn platform_compatibility_failure_before_commit_restores_original_and_keeps_backup() { + let (dir, path, target) = fixture(); + let before = snapshot(&path); + let error = upgrade_with_hook( + &path, + &target, + |_| Ok(()), + || Err(UpgradeError::Verification), + ) + .unwrap_err(); + assert!(matches!(error, UpgradeError::Verification)); + assert_eq!(snapshot(&path), before); + let backups = backup_files(dir.path()); + assert_eq!(backups.len(), 1); + assert_eq!(snapshot(&backups[0]), before); + // A restart uses a fresh stage, so an interrupted attempt cannot poison the next one. + let next = dir.path().join("next.sqlite"); + Connection::open(&next) + .unwrap() + .execute_batch(TARGET_SCHEMA) + .unwrap(); + assert!(upgrade(&path, &next, |_| Ok(())).unwrap().is_some()); +} + +#[test] +fn platform_compatibility_typed_validation_failure_never_rewrites_original() { + let (dir, path, target) = fixture(); + let before = snapshot(&path); + let error = upgrade(&path, &target, |_| { + Err(UpgradeError::TypedValidation(Box::new( + std::io::Error::other("synthetic invalid public blob"), + ))) + }) + .unwrap_err(); + assert!(matches!(error, UpgradeError::TypedValidation(_))); + assert_eq!(snapshot(&path), before); + assert_eq!(snapshot(&backup_files(dir.path())[0]), before); +} + +#[test] +fn platform_compatibility_rejects_unknown_history_schema_and_roster_without_changes() { + for sql in [ + "UPDATE refinery_schema_history SET checksum = '1' WHERE version = 4", + "ALTER TABLE wallets ADD COLUMN unknown TEXT", + "INSERT INTO meta_global VALUES ('det:identity_index:v1', X'02ff', 0)", + "INSERT INTO meta_global VALUES ('det:identity_index:v1', X'01ff', 0)", + "INSERT INTO meta_global VALUES ('det:identity_index:v1', X'010000', 0)", + ] { + let (dir, path, target) = fixture(); + Connection::open(&path).unwrap().execute_batch(sql).unwrap(); + let before = snapshot(&path); + assert!(!matches!( + upgrade(&path, &target, |_| panic!( + "Unknown input must not reach validation" + )), + Ok(Some(_)) + )); + assert_eq!(snapshot(&path), before); + assert!(backup_files(dir.path()).is_empty()); + } +} + +#[test] +fn platform_compatibility_fresh_database_is_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("fresh.sqlite"); + Connection::open(&path).unwrap(); + let before = snapshot(&path); + assert!( + upgrade(&path, &dir.path().join("unused.sqlite"), |_| panic!( + "Fresh database" + )) + .unwrap() + .is_none() + ); + assert_eq!(snapshot(&path), before); + assert!(backup_files(dir.path()).is_empty()); +} + +#[test] +fn platform_compatibility_wal_snapshot_includes_committed_uncheckpointed_data() { + let (_dir, path, target) = fixture(); + let live = Connection::open(&path).unwrap(); + live.execute_batch("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; INSERT INTO meta_global VALUES ('wal-fixture', X'00ff', 42)").unwrap(); + let before = snapshot(&path); + let backup = upgrade(&path, &target, |_| Ok(())).unwrap().unwrap(); + assert_eq!(snapshot(&backup), before); + let conn = Connection::open(&path).unwrap(); + assert_eq!( + conn.query_row( + "SELECT value FROM meta_global WHERE key='wal-fixture'", + [], + |r| r.get::<_, Vec>(0) + ) + .unwrap(), + [0, 255] + ); +} + +#[test] +fn platform_compatibility_missing_roster_with_identity_sidecar_is_ambiguous() { + let (dir, path, target) = fixture(); + Connection::open(&path).unwrap().execute_batch("INSERT INTO identities VALUES (zeroblob(32), NULL, NULL, X'00', 1); INSERT INTO meta_identity VALUES (zeroblob(32), 'det:identity:v1', X'00', 0);").unwrap(); + let before = snapshot(&path); + assert!(matches!( + upgrade(&path, &target, |_| Ok(())), + Err(UpgradeError::IdentityRoster) + )); + assert_eq!(snapshot(&path), before); + assert!(backup_files(dir.path()).is_empty()); +} + +#[test] +fn platform_compatibility_process_exit_rolls_back_and_keeps_valid_backup() { + const CHILD_DIR: &str = "DET_PLATFORM_COMPAT_CRASH_FIXTURE_DIR"; + if let Some(dir) = std::env::var_os(CHILD_DIR) { + let dir = PathBuf::from(dir); + upgrade_with_hook( + &dir.join("wallet.sqlite"), + &dir.join("target.sqlite"), + |_| Ok(()), + || std::process::exit(77), + ) + .unwrap(); + panic!("The crash hook must exit the process."); + } + let (dir, path, _target) = fixture(); + let before = snapshot(&path); + let test_name = std::thread::current().name().unwrap().to_owned(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name, "--nocapture"]) + .env(CHILD_DIR, dir.path()) + .status() + .unwrap(); + assert_eq!(status.code(), Some(77)); + assert_eq!(snapshot(&path), before); + assert_eq!(snapshot(&backup_files(dir.path())[0]), before); +} + +#[test] +fn platform_compatibility_writer_exclusion_covers_staged_validation() { + let (_dir, path, target) = fixture(); + upgrade(&path, &target, |_| { + let other = Connection::open(&path)?; + other.busy_timeout(std::time::Duration::ZERO)?; + let error = other.execute("INSERT INTO meta_global VALUES ('concurrent', X'00', 0)", []).unwrap_err(); + assert!(matches!(error, rusqlite::Error::SqliteFailure(e, _) if e.code == rusqlite::ErrorCode::DatabaseBusy)); + Ok(()) + }).unwrap(); +} diff --git a/src/wallet_backend/platform_compatibility/fixtures/67d4ef3.sql b/src/wallet_backend/platform_compatibility/fixtures/67d4ef3.sql new file mode 100644 index 000000000..fce255c07 --- /dev/null +++ b/src/wallet_backend/platform_compatibility/fixtures/67d4ef3.sql @@ -0,0 +1,398 @@ +-- Materialized empty schema from dashpay/platform 67d4ef3. No user data. +PRAGMA application_id=1347180372; +CREATE TABLE account_registrations ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL CHECK (account_type IN ('standard_bip44', 'standard_bip32', 'coinjoin', 'identity_registration', 'identity_topup', 'identity_topup_unbound', 'identity_invitation', 'asset_lock_address_topup', 'asset_lock_shielded_topup', 'provider_voting', 'provider_owner', 'provider_operator', 'provider_platform', 'dashpay_receiving', 'dashpay_external', 'platform_payment')), + account_index INTEGER NOT NULL, + -- Discriminators sharing (account_type, account_index) across distinct + -- accounts: PlatformPayment key_class and the DashPay (user, friend) + -- identity pair. Sentinel default for variants without that axis. + key_class INTEGER NOT NULL DEFAULT 0, + user_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + friend_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + account_xpub_bytes BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE "asset_locks" ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('built', 'broadcast', 'is_locked', 'chain_locked', 'consumed', 'recovered_from_chain')), + account_index INTEGER NOT NULL, + identity_index INTEGER NOT NULL, + amount_duffs INTEGER NOT NULL, + lifecycle_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE contacts ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + state TEXT NOT NULL CHECK (state IN ('sent', 'received', 'established')), + outgoing_request BLOB, + incoming_request BLOB, + alias TEXT, + note TEXT, + is_hidden INTEGER, + accepted_accounts BLOB, + -- G1c: set when external-account registration permanently fails for a + -- contact (so the sync sweep stops retrying a poisoned channel); + -- cleared on a superseding rotation. Nullable — readers treat NULL as + -- `false`. + payment_channel_broken INTEGER, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, contact_id), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_address_pool ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL, + account_index INTEGER NOT NULL, + key_class INTEGER NOT NULL DEFAULT 0, + user_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + friend_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)), + address_index INTEGER NOT NULL, + script BLOB NOT NULL, + used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)), public_key BLOB NULL, key_type INTEGER NULL CHECK (key_type IS NULL OR key_type IN (0, 1, 2)), reserved_at INTEGER NULL, + PRIMARY KEY (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id, pool_type, address_index), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_instant_locks ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + islock_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_sync_state ( + wallet_id BLOB NOT NULL PRIMARY KEY, + last_processed_height INTEGER, + synced_height INTEGER, + -- Bincode-encoded `dashcore::ephemerealdata::chain_lock::ChainLock`. + -- NULL until the first ChainLock has been applied and flushed. + last_applied_chain_lock BLOB, + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE "core_transactions" ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + height INTEGER, + block_hash BLOB, + block_time INTEGER, + finalized INTEGER NOT NULL, + record_blob BLOB, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_utxos ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + value INTEGER NOT NULL, + script BLOB NOT NULL, + spent INTEGER NOT NULL, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE dashpay_payments_overlay ( + identity_id BLOB NOT NULL, + payment_id TEXT NOT NULL, + overlay_blob BLOB NOT NULL, + PRIMARY KEY (identity_id, payment_id), + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +CREATE TABLE dashpay_profiles ( + identity_id BLOB NOT NULL PRIMARY KEY, + profile_blob BLOB NOT NULL, + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +CREATE TABLE dpns_name_states ( + wallet_id BLOB NOT NULL, + document_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + label TEXT NOT NULL, + normalized_label TEXT NOT NULL, + normalized_parent_domain TEXT NOT NULL, + price INTEGER CHECK (price IS NULL OR price >= 0), + status TEXT NOT NULL CHECK (status IN ('owned', 'sold', 'transferred')), + counterparty_id BLOB, + created_at_ms INTEGER, + updated_at_ms INTEGER, + transferred_at_ms INTEGER, + last_synced_at_ms INTEGER NOT NULL, + PRIMARY KEY (wallet_id, document_id), + CHECK ((status = 'owned') = (counterparty_id IS NULL)), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE + ); +CREATE TABLE identities ( + identity_id BLOB NOT NULL PRIMARY KEY, + wallet_id BLOB, + identity_index INTEGER, + entry_blob BLOB NOT NULL, + tombstoned INTEGER NOT NULL, + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE identity_keys ( + -- NULLABLE: NULL is the canonical `owned by no wallet`, matching + -- `identities.wallet_id`. Read the two FKs below with that in mind — + -- SQLite's default MATCH SIMPLE skips foreign-key enforcement + -- entirely when ANY column of the child key is NULL, so for a + -- NULL-scoped row BOTH FKs are dormant and neither constrains which + -- identity the key names. The two triggers after this table exist + -- precisely to replace that dormancy; the FKs alone are NOT + -- sufficient, and a NULL-scoped row is only as safe as the triggers. + wallet_id BLOB, + identity_id BLOB NOT NULL, + key_id INTEGER NOT NULL, + public_key_blob BLOB NOT NULL, + public_key_hash BLOB NOT NULL, + -- Reserved for a future typed projection; always NULL today. + -- derivation_indices lives inside public_key_blob (the + -- IdentityKeyWire blob is the single source of truth). + derivation_blob BLOB, + -- `wallet_id` is deliberately NOT part of the key. `identities` + -- keys on `identity_id` ALONE, so an identity has exactly one row + -- and exactly one owning wallet — the scope column here carries no + -- discriminating power, it is a denormalised copy of + -- `identities.wallet_id`. The wider `(wallet_id, identity_id, + -- key_id)` key was the enabling condition for the duplicate-row + -- corruption: it let the same key exist twice under two scopes, a + -- state the domain (`IdentityKeysChangeSet`, keyed + -- `(identity_id, key_id)`) cannot express. Narrowing the key makes + -- that row pair unrepresentable rather than merely rejected. + PRIMARY KEY (identity_id, key_id), + -- Belt-and-braces: the compound FK below already implies a live + -- `wallets` row (the matched `identities` row carries this same + -- non-NULL wallet_id and is itself FK'd to `wallets`). Kept as an + -- explicit statement of intent and a second cascade path. + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE, + -- Compound: a key may only be filed under the wallet that OWNS the + -- identity. The single-column form allowed a key to name an identity + -- parented to a different wallet — a row the per-wallet reader can + -- never resolve, surfacing much later as a fatal OrphanedIdentityEntry. + FOREIGN KEY (wallet_id, identity_id) + REFERENCES identities(wallet_id, identity_id) ON DELETE CASCADE +); +CREATE TABLE ignored_senders ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + sender_id BLOB NOT NULL, + ignored_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, sender_id), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE invitations ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('created', 'claimed', 'reclaimed')), + funding_index INTEGER NOT NULL, + amount_duffs INTEGER NOT NULL, + expiry_unix INTEGER NOT NULL, + created_at_secs INTEGER NOT NULL, + has_inviter INTEGER NOT NULL, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE + ); +CREATE TABLE meta_contact ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, contact_id, key) +); +CREATE TABLE meta_data_versions ( + wallet_id BLOB NOT NULL, + domain TEXT NOT NULL, + seq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, domain) +); +CREATE TABLE meta_global ( + key TEXT NOT NULL PRIMARY KEY CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +CREATE TABLE meta_identity ( + identity_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (identity_id, key) +); +CREATE TABLE meta_platform_address ( + wallet_id BLOB NOT NULL, + address BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, address, key) +); +CREATE TABLE meta_store_generation ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0), + generation BLOB NOT NULL +); +CREATE TABLE meta_token ( + identity_id BLOB NOT NULL, + token_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (identity_id, token_id, key) +); +CREATE TABLE meta_wallet ( + wallet_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, key) +); +CREATE TABLE pending_contact_crypto ( + wallet_id BLOB NOT NULL, + owner_identity_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('register_receiving', 'register_external', 'contact_info_decrypt', 'auto_accept')), + payload BLOB NOT NULL, + enqueued_at_ms INTEGER NOT NULL, + PRIMARY KEY (wallet_id, owner_identity_id, contact_id, kind), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE platform_address_sync ( + wallet_id BLOB NOT NULL PRIMARY KEY, + sync_height INTEGER NOT NULL, + sync_timestamp INTEGER NOT NULL, + last_known_recent_block INTEGER NOT NULL, + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE platform_addresses ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL, + address_index INTEGER NOT NULL, + address BLOB NOT NULL, + balance INTEGER NOT NULL, + nonce INTEGER NOT NULL, as_of_height INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, address), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE refinery_schema_history( + version int4 PRIMARY KEY, + name VARCHAR(255), + applied_on VARCHAR(255), + checksum VARCHAR(255)); +CREATE TABLE shielded_viewing_keys ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL CHECK (account_index BETWEEN 0 AND 4294967295), + viewing_key BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_index), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE token_balances ( + identity_id BLOB NOT NULL, + token_id BLOB NOT NULL, + balance INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (identity_id, token_id), + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +CREATE TABLE tracked_masternodes ( + network TEXT NOT NULL CHECK (network IN ('mainnet', 'testnet', 'devnet', 'regtest')), + pro_tx_hash BLOB NOT NULL CHECK (length(pro_tx_hash) = 32), + label TEXT, + added_at INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + PRIMARY KEY (network, pro_tx_hash) + ); +CREATE TABLE wallets ( + wallet_id BLOB NOT NULL PRIMARY KEY, + network TEXT NOT NULL CHECK (network IN ('mainnet', 'testnet', 'devnet', 'regtest')), + birth_height INTEGER NOT NULL +); +CREATE INDEX idx_core_address_pool_script + ON core_address_pool(wallet_id, script); +CREATE INDEX idx_core_address_pool_used + ON core_address_pool(wallet_id, used); +CREATE INDEX idx_core_transactions_height ON core_transactions(wallet_id, height); +CREATE INDEX idx_core_utxos_spent ON core_utxos(wallet_id, spent); +CREATE INDEX idx_identities_wallet ON identities(wallet_id); +CREATE UNIQUE INDEX idx_identities_wallet_identity ON identities(wallet_id, identity_id); +CREATE INDEX idx_identity_keys_wallet_identity ON identity_keys(wallet_id, identity_id); +CREATE TRIGGER cascade_meta_contact_on_contact_delete +AFTER DELETE ON contacts +FOR EACH ROW +BEGIN + DELETE FROM meta_contact + WHERE wallet_id = OLD.wallet_id + AND owner_id = OLD.owner_id + AND contact_id = OLD.contact_id; +END; +CREATE TRIGGER cascade_meta_data_versions_on_wallet_delete +AFTER DELETE ON wallets +FOR EACH ROW +BEGIN + DELETE FROM meta_data_versions WHERE wallet_id = OLD.wallet_id; +END; +CREATE TRIGGER cascade_meta_on_identity_delete +AFTER DELETE ON identities +FOR EACH ROW +BEGIN + DELETE FROM meta_identity WHERE identity_id = OLD.identity_id; + DELETE FROM meta_token WHERE identity_id = OLD.identity_id; +END; +CREATE TRIGGER cascade_meta_on_wallet_delete +AFTER DELETE ON wallets +FOR EACH ROW +BEGIN + DELETE FROM meta_wallet WHERE wallet_id = OLD.wallet_id; + DELETE FROM meta_contact WHERE wallet_id = OLD.wallet_id; + DELETE FROM meta_platform_address WHERE wallet_id = OLD.wallet_id; +END; +CREATE TRIGGER cascade_meta_platform_address_on_address_delete +AFTER DELETE ON platform_addresses +FOR EACH ROW +BEGIN + DELETE FROM meta_platform_address + WHERE wallet_id = OLD.wallet_id AND address = OLD.address; +END; +CREATE TRIGGER cascade_meta_token_on_token_balance_delete +AFTER DELETE ON token_balances +FOR EACH ROW +BEGIN + DELETE FROM meta_token + WHERE identity_id = OLD.identity_id AND token_id = OLD.token_id; +END; +CREATE TRIGGER identity_keys_null_scope_requires_unowned_identity +BEFORE INSERT ON identity_keys +FOR EACH ROW WHEN NEW.wallet_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'identity_keys.wallet_id is NULL but the named identity is missing or wallet-owned') + WHERE NOT EXISTS ( + SELECT 1 FROM identities i + WHERE i.identity_id = NEW.identity_id AND i.wallet_id IS NULL + ); +END; +CREATE TRIGGER identity_keys_null_scope_requires_unowned_identity_on_update +BEFORE UPDATE ON identity_keys +FOR EACH ROW WHEN NEW.wallet_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'identity_keys.wallet_id is NULL but the named identity is missing or wallet-owned') + WHERE NOT EXISTS ( + SELECT 1 FROM identities i + WHERE i.identity_id = NEW.identity_id AND i.wallet_id IS NULL + ); +END; +INSERT INTO refinery_schema_history VALUES (1, 'initial', '2026-09-10T00:00:00Z', '17047616343160871465'); +INSERT INTO refinery_schema_history VALUES (2, 'address_height_pin', '2026-09-10T00:00:00Z', '9304453063389616695'); +INSERT INTO refinery_schema_history VALUES (3, 'unified', '2026-09-10T00:00:00Z', '14737834287968956387'); +INSERT INTO refinery_schema_history VALUES (4, 'invitations', '2026-09-10T00:00:00Z', '10484261583046002776'); +INSERT INTO refinery_schema_history VALUES (5, 'pool_public_key', '2026-09-10T00:00:00Z', '1184376266715578615'); +INSERT INTO refinery_schema_history VALUES (6, 'pool_reserved_at', '2026-09-10T00:00:00Z', '11787317857065590354'); +INSERT INTO refinery_schema_history VALUES (7, 'drop_core_utxo_metadata', '2026-09-10T00:00:00Z', '8136185229636655136'); +INSERT INTO refinery_schema_history VALUES (8, 'shielded_viewing_keys', '2026-09-10T00:00:00Z', '1296229231248300117'); +INSERT INTO refinery_schema_history VALUES (9, 'single_source_core_confirmation_height', '2026-09-10T00:00:00Z', '18418537157120884496'); +INSERT INTO refinery_schema_history VALUES (10, 'asset_lock_recovered_status', '2026-09-10T00:00:00Z', '2416860902556468199'); +INSERT INTO refinery_schema_history VALUES (11, 'dpns_name_states', '2026-09-10T00:00:00Z', '6272036451822234550'); +INSERT INTO refinery_schema_history VALUES (12, 'purge_legacy_empty_script_spent_utxos', '2026-09-10T00:00:00Z', '5078144809172324082'); +INSERT INTO refinery_schema_history VALUES (13, 'tracked_masternodes', '2026-09-10T00:00:00Z', '989798159842040942'); +INSERT INTO refinery_schema_history VALUES (14, 'identity_keys_null_scope_requires_existing_identity', '2026-09-10T00:00:00Z', '9865513418054625997'); +INSERT INTO meta_store_generation VALUES (0, X'11111111111111111111111111111111'); diff --git a/src/wallet_backend/platform_compatibility/fixtures/README.md b/src/wallet_backend/platform_compatibility/fixtures/README.md new file mode 100644 index 000000000..40c499b5a --- /dev/null +++ b/src/wallet_backend/platform_compatibility/fixtures/README.md @@ -0,0 +1,27 @@ +# Platform database compatibility fixtures + +`67d4ef3.sql` and `e3cd7cf.sql` are empty materialized schemas from +`dashpay/platform` revisions `67d4ef3f6340a1e983229b6870ef60cf7573602a` +and `e3cd7cf5a34dd69b59d633e532a1ac570870b03f` respectively. Their SQL was +rendered from `packages/rs-platform-wallet-storage/migrations/V*.rs` using +the matching revision's enum label constants, then applied in version order +to an empty SQLite database. The new schema also includes the V011 Rust +conversion hook's removal of the legacy `account_address_pools` and +`core_derived_addresses` tables. SQL alone does not reproduce that schema; +the integration tests compare it with the actual public migration runner. +The resulting `sqlite_master` definitions are +stored in table/index/trigger order. Refinery history uses its SipHasher13 +hash over migration name, i32 version and rendered SQL. Timestamps and the +store-generation token are deterministic fixture values. + +The production bridge compares these exact histories and materialized +schemas before translating data. The destination itself is created by the +current public `SqlitePersister::open`, not by the fixture SQL. Updating +either pinned revision requires reviewing and regenerating these guards. + +`public-rows.sql` contains only public synthetic rows from that old revision's +`packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db`, after +applying its V002–V014 SQL. It selects the registered A1 wallet and its rows; +the source fixture's separate empty B2 wallet has no account registration. +The rows include a public account key, an identity, contact, transaction, +UTXO and sync state. No seeds, private keys or real profiles are included. diff --git a/src/wallet_backend/platform_compatibility/fixtures/e3cd7cf.sql b/src/wallet_backend/platform_compatibility/fixtures/e3cd7cf.sql new file mode 100644 index 000000000..038efed03 --- /dev/null +++ b/src/wallet_backend/platform_compatibility/fixtures/e3cd7cf.sql @@ -0,0 +1,407 @@ +-- Materialized empty schema from dashpay/platform e3cd7cf. No user data. +-- Includes the V011 Rust conversion hook dropping legacy pool tables. +PRAGMA application_id=1347180372; +CREATE TABLE "account_registrations" ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL CHECK (account_type IN ('standard', 'standard_bip44', 'standard_bip32', 'coinjoin', 'identity_registration', 'identity_topup', 'identity_topup_unbound', 'identity_invitation', 'asset_lock_address_topup', 'asset_lock_shielded_topup', 'provider_voting', 'provider_owner', 'provider_operator', 'provider_platform', 'dashpay_receiving', 'dashpay_external', 'platform_payment')), + account_index INTEGER NOT NULL, + key_class INTEGER NOT NULL DEFAULT 0, + user_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + friend_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + account_xpub_bytes BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE "asset_locks" ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('built', 'broadcast', 'is_locked', 'chain_locked', 'consumed', 'recovered_from_chain')), + account_index INTEGER NOT NULL, + identity_index INTEGER NOT NULL, + amount_duffs INTEGER NOT NULL, + lifecycle_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE contacts ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + state TEXT NOT NULL CHECK (state IN ('sent', 'received', 'established')), + outgoing_request BLOB, + incoming_request BLOB, + alias TEXT, + note TEXT, + is_hidden INTEGER, + accepted_accounts BLOB, + -- G1c: set when external-account registration permanently fails for a + -- contact (so the sync sweep stops retrying a poisoned channel); + -- cleared on a superseding rotation. Nullable — readers treat NULL as + -- `false`. + payment_channel_broken INTEGER, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, contact_id), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_address_pool ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL, + account_index INTEGER NOT NULL, + key_class INTEGER NOT NULL DEFAULT 0, + user_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + friend_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)), + address_index INTEGER NOT NULL, + script BLOB NOT NULL, + used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)), public_key BLOB NULL, key_type INTEGER NULL CHECK (key_type IS NULL OR key_type IN (0, 1, 2)), reserved_at INTEGER NULL, + PRIMARY KEY (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id, pool_type, address_index), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_instant_locks ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + islock_blob BLOB NOT NULL, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_sync_state ( + wallet_id BLOB NOT NULL PRIMARY KEY, + last_processed_height INTEGER, + synced_height INTEGER, chainlock_height INTEGER, last_applied_chain_lock BLOB, + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE "core_transactions" ( + wallet_id BLOB NOT NULL, + txid BLOB NOT NULL, + height INTEGER, + block_hash BLOB, + block_time INTEGER, + finalized INTEGER NOT NULL, + record_blob BLOB, + PRIMARY KEY (wallet_id, txid), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE core_utxos ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + value INTEGER NOT NULL, + script BLOB NOT NULL, + spent INTEGER NOT NULL, + spent_in_txid BLOB, winner_mined_height INTEGER, is_sweep_placeholder INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE dashpay_payments_overlay ( + identity_id BLOB NOT NULL, + payment_id TEXT NOT NULL, + overlay_blob BLOB NOT NULL, + PRIMARY KEY (identity_id, payment_id), + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +CREATE TABLE dashpay_profiles ( + identity_id BLOB NOT NULL PRIMARY KEY, + profile_blob BLOB NOT NULL, + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +CREATE TABLE dpns_name_states ( + wallet_id BLOB NOT NULL, + document_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + label TEXT NOT NULL, + normalized_label TEXT NOT NULL, + normalized_parent_domain TEXT NOT NULL, + price INTEGER CHECK (price IS NULL OR price >= 0), + status TEXT NOT NULL CHECK (status IN ('owned', 'sold', 'transferred')), + counterparty_id BLOB, + created_at_ms INTEGER, + updated_at_ms INTEGER, + transferred_at_ms INTEGER, + last_synced_at_ms INTEGER NOT NULL, + PRIMARY KEY (wallet_id, document_id), + CHECK ((status = 'owned') = (counterparty_id IS NULL)), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE + ); +CREATE TABLE identities ( + identity_id BLOB NOT NULL PRIMARY KEY, + wallet_id BLOB, + identity_index INTEGER, + entry_blob BLOB NOT NULL, + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE "identity_keys" ( + wallet_id BLOB, + identity_id BLOB NOT NULL, + key_id INTEGER NOT NULL, + public_key_blob BLOB NOT NULL, + public_key_hash BLOB NOT NULL, + -- Reserved for a future typed projection; always NULL today. + -- derivation_indices lives inside public_key_blob (the IdentityKeyWire + -- blob is the single source of truth). + derivation_blob BLOB, + PRIMARY KEY (identity_id, key_id), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE, + FOREIGN KEY (wallet_id, identity_id) + REFERENCES identities(wallet_id, identity_id) ON DELETE CASCADE +); +CREATE TABLE identity_scan_failed_indices ( + wallet_id BLOB NOT NULL, + failed_index INTEGER NOT NULL CHECK (failed_index >= 0), + PRIMARY KEY (wallet_id, failed_index), + FOREIGN KEY (wallet_id) REFERENCES identity_scan_states(wallet_id) ON DELETE CASCADE +); +CREATE TABLE identity_scan_states ( + wallet_id BLOB NOT NULL PRIMARY KEY, + complete INTEGER NOT NULL CHECK (complete IN (0, 1)), + probed_from INTEGER NOT NULL CHECK (probed_from >= 0), + probed_through INTEGER NOT NULL CHECK (probed_through >= probed_from), + unlocated_gap INTEGER NOT NULL CHECK (unlocated_gap IN (0, 1)), + -- A scan cannot both have answered everything and be sitting on a gap + -- nobody could name. + CHECK (complete = 0 OR unlocated_gap = 0), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE ignored_senders ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + sender_id BLOB NOT NULL, + ignored_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, sender_id), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE invitations ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('created', 'claimed', 'reclaimed')), + funding_index INTEGER NOT NULL, + amount_duffs INTEGER NOT NULL, + expiry_unix INTEGER NOT NULL, + created_at_secs INTEGER NOT NULL, + has_inviter INTEGER NOT NULL, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE + ); +CREATE TABLE meta_contact ( + wallet_id BLOB NOT NULL, + owner_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, owner_id, contact_id, key) +); +CREATE TABLE meta_data_versions ( + wallet_id BLOB NOT NULL, + domain TEXT NOT NULL, + seq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, domain) +); +CREATE TABLE meta_global ( + key TEXT NOT NULL PRIMARY KEY CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +CREATE TABLE meta_identity ( + identity_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (identity_id, key) +); +CREATE TABLE meta_platform_address ( + wallet_id BLOB NOT NULL, + address BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, address, key) +); +CREATE TABLE meta_store_generation ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0), + generation BLOB NOT NULL +); +CREATE TABLE meta_token ( + identity_id BLOB NOT NULL, + token_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (identity_id, token_id, key) +); +CREATE TABLE meta_wallet ( + wallet_id BLOB NOT NULL, + key TEXT NOT NULL CHECK (length(key) BETWEEN 1 AND 128), + value BLOB NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (wallet_id, key) +); +CREATE TABLE pending_contact_crypto ( + wallet_id BLOB NOT NULL, + owner_identity_id BLOB NOT NULL, + contact_id BLOB NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('register_receiving', 'register_external', 'contact_info_decrypt', 'auto_accept')), + payload BLOB NOT NULL, + enqueued_at_ms INTEGER NOT NULL, + PRIMARY KEY (wallet_id, owner_identity_id, contact_id, kind), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE platform_address_sync ( + wallet_id BLOB NOT NULL PRIMARY KEY, + sync_height INTEGER NOT NULL, + sync_timestamp INTEGER NOT NULL, + last_known_recent_block INTEGER NOT NULL, + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE platform_addresses ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL, + address_index INTEGER NOT NULL, + address BLOB NOT NULL, + balance INTEGER NOT NULL, + nonce INTEGER NOT NULL, as_of_height INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, address), + FOREIGN KEY (wallet_id) REFERENCES "wallets"(wallet_id) ON DELETE CASCADE +); +CREATE TABLE refinery_schema_history( + version int4 PRIMARY KEY, + name VARCHAR(255), + applied_on VARCHAR(255), + checksum VARCHAR(255)); +CREATE TABLE shielded_viewing_keys ( + wallet_id BLOB NOT NULL, + account_index INTEGER NOT NULL CHECK (account_index BETWEEN 0 AND 4294967295), + viewing_key BLOB NOT NULL, + PRIMARY KEY (wallet_id, account_index), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +CREATE TABLE token_balances ( + identity_id BLOB NOT NULL, + token_id BLOB NOT NULL, + balance INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (identity_id, token_id), + FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE +); +CREATE TABLE tracked_masternodes ( + network TEXT NOT NULL CHECK (network IN ('mainnet', 'testnet', 'devnet', 'regtest')), + pro_tx_hash BLOB NOT NULL CHECK (length(pro_tx_hash) = 32), + label TEXT, + added_at INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + PRIMARY KEY (network, pro_tx_hash) + ); +CREATE TABLE "wallets" ( + wallet_id BLOB NOT NULL PRIMARY KEY, + network TEXT NOT NULL CHECK (network IN ('mainnet', 'testnet', 'devnet', 'regtest')), + birth_height INTEGER NOT NULL +); +CREATE INDEX idx_contacts_owner ON contacts(owner_id); +CREATE INDEX idx_core_address_pool_script + ON core_address_pool(wallet_id, script); +CREATE INDEX idx_core_address_pool_used + ON core_address_pool(wallet_id, used); +CREATE INDEX idx_core_transactions_height ON core_transactions(wallet_id, height); +CREATE INDEX idx_core_utxos_spent ON core_utxos(wallet_id, spent); +CREATE INDEX idx_core_utxos_unmaterialized ON core_utxos(wallet_id, winner_mined_height) + WHERE is_sweep_placeholder = 1; +CREATE INDEX idx_identities_wallet ON identities(wallet_id); +CREATE UNIQUE INDEX idx_identities_wallet_identity ON identities(wallet_id, identity_id); +CREATE INDEX idx_identity_keys_wallet_identity ON identity_keys(wallet_id, identity_id); +CREATE INDEX idx_ignored_senders_owner ON ignored_senders(owner_id); +CREATE INDEX idx_pending_contact_crypto_owner ON pending_contact_crypto(owner_identity_id); +CREATE TRIGGER cascade_children_on_identity_delete +AFTER DELETE ON identities +FOR EACH ROW +BEGIN + DELETE FROM identity_keys WHERE identity_id = OLD.identity_id; + DELETE FROM contacts WHERE owner_id = OLD.identity_id; + DELETE FROM ignored_senders WHERE owner_id = OLD.identity_id; + DELETE FROM pending_contact_crypto WHERE owner_identity_id = OLD.identity_id; +END; +CREATE TRIGGER cascade_meta_contact_on_contact_delete +AFTER DELETE ON contacts +FOR EACH ROW +BEGIN + DELETE FROM meta_contact + WHERE wallet_id = OLD.wallet_id + AND owner_id = OLD.owner_id + AND contact_id = OLD.contact_id; +END; +CREATE TRIGGER cascade_meta_data_versions_on_wallet_delete +AFTER DELETE ON wallets +FOR EACH ROW +BEGIN + DELETE FROM meta_data_versions WHERE wallet_id = OLD.wallet_id; +END; +CREATE TRIGGER cascade_meta_on_identity_delete +AFTER DELETE ON identities +FOR EACH ROW +BEGIN + DELETE FROM meta_identity WHERE identity_id = OLD.identity_id; + DELETE FROM meta_token WHERE identity_id = OLD.identity_id; +END; +CREATE TRIGGER cascade_meta_on_wallet_delete +AFTER DELETE ON "wallets" +FOR EACH ROW +BEGIN + DELETE FROM meta_wallet WHERE wallet_id = OLD.wallet_id; + DELETE FROM meta_contact WHERE wallet_id = OLD.wallet_id; + DELETE FROM meta_platform_address WHERE wallet_id = OLD.wallet_id; +END; +CREATE TRIGGER cascade_meta_platform_address_on_address_delete +AFTER DELETE ON platform_addresses +FOR EACH ROW +BEGIN + DELETE FROM meta_platform_address + WHERE wallet_id = OLD.wallet_id AND address = OLD.address; +END; +CREATE TRIGGER cascade_meta_token_on_token_balance_delete +AFTER DELETE ON token_balances +FOR EACH ROW +BEGIN + DELETE FROM meta_token + WHERE identity_id = OLD.identity_id AND token_id = OLD.token_id; +END; +CREATE TRIGGER identity_keys_null_scope_requires_unowned_identity +BEFORE INSERT ON identity_keys +FOR EACH ROW WHEN NEW.wallet_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'identity_keys.wallet_id is NULL but the named identity is missing or wallet-owned') + WHERE NOT EXISTS ( + SELECT 1 FROM identities i + WHERE i.identity_id = NEW.identity_id AND i.wallet_id IS NULL + ); +END; +CREATE TRIGGER identity_keys_null_scope_requires_unowned_identity_on_update +BEFORE UPDATE ON identity_keys +FOR EACH ROW WHEN NEW.wallet_id IS NULL +BEGIN + SELECT RAISE(ABORT, 'identity_keys.wallet_id is NULL but the named identity is missing or wallet-owned') + WHERE NOT EXISTS ( + SELECT 1 FROM identities i + WHERE i.identity_id = NEW.identity_id AND i.wallet_id IS NULL + ); +END; +CREATE TRIGGER setnull_core_utxos_on_tx_delete AFTER DELETE ON core_transactions +BEGIN + UPDATE core_utxos SET spent_in_txid = NULL + WHERE wallet_id = OLD.wallet_id AND spent_in_txid = OLD.txid; +END; +INSERT INTO refinery_schema_history VALUES (1, 'initial', '2026-09-10T00:00:00Z', '16458714021387590417'); +INSERT INTO refinery_schema_history VALUES (2, 'address_height_pin', '2026-09-10T00:00:00Z', '9304453063389616695'); +INSERT INTO refinery_schema_history VALUES (3, 'invitations', '2026-09-10T00:00:00Z', '6768710306020099565'); +INSERT INTO refinery_schema_history VALUES (4, 'asset_lock_recovered_status', '2026-09-10T00:00:00Z', '14446754812616312642'); +INSERT INTO refinery_schema_history VALUES (5, 'dpns_name_states', '2026-09-10T00:00:00Z', '16834970739141546284'); +INSERT INTO refinery_schema_history VALUES (6, 'tracked_masternodes', '2026-09-10T00:00:00Z', '13718514320935649081'); +INSERT INTO refinery_schema_history VALUES (7, 'utxo_sweep_winner_height', '2026-09-10T00:00:00Z', '4410976868559573281'); +INSERT INTO refinery_schema_history VALUES (8, 'rehydration_base_schema', '2026-09-10T00:00:00Z', '8883371305674850298'); +INSERT INTO refinery_schema_history VALUES (9, 'unified', '2026-09-10T00:00:00Z', '8389295906542582065'); +INSERT INTO refinery_schema_history VALUES (10, 'pool_public_key', '2026-09-10T00:00:00Z', '15088205568906581463'); +INSERT INTO refinery_schema_history VALUES (11, 'pool_reserved_at', '2026-09-10T00:00:00Z', '1184102594786438566'); +INSERT INTO refinery_schema_history VALUES (12, 'drop_core_utxo_metadata', '2026-09-10T00:00:00Z', '14281733277552006158'); +INSERT INTO refinery_schema_history VALUES (13, 'shielded_viewing_keys', '2026-09-10T00:00:00Z', '2886147678304763509'); +INSERT INTO refinery_schema_history VALUES (14, 'single_source_core_confirmation_height', '2026-09-10T00:00:00Z', '13462228277166821110'); +INSERT INTO refinery_schema_history VALUES (15, 'purge_legacy_empty_script_spent_utxos', '2026-09-10T00:00:00Z', '723175730717787103'); +INSERT INTO refinery_schema_history VALUES (16, 'identity_keys_null_scope_requires_existing_identity', '2026-09-10T00:00:00Z', '5573841766625796742'); +INSERT INTO refinery_schema_history VALUES (17, 'identity_scan_state', '2026-09-10T00:00:00Z', '14086773559107496341'); +INSERT INTO refinery_schema_history VALUES (18, 'identity_hard_delete', '2026-09-10T00:00:00Z', '14764815300602197850'); +INSERT INTO meta_store_generation VALUES (0, X'22222222222222222222222222222222'); diff --git a/src/wallet_backend/platform_compatibility/fixtures/public-rows.sql b/src/wallet_backend/platform_compatibility/fixtures/public-rows.sql new file mode 100644 index 000000000..aec0b3378 --- /dev/null +++ b/src/wallet_backend/platform_compatibility/fixtures/public-rows.sql @@ -0,0 +1,11 @@ +-- Public rows from platform 67d4ef3 populated_v001.db after its V002-V014 SQL. +-- The fixture selects its registered wallet; no seeds, private keys or user profiles. +PRAGMA foreign_keys=OFF; +INSERT INTO "account_registrations" VALUES(X'A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1','standard_bip44',0,0,X'0000000000000000000000000000000000000000000000000000000000000000',X'0000000000000000000000000000000000000000000000000000000000000000',X'0000006F787075623636314D794D77417152626346745867533573594A414271714739594C6D433451315264617039675345384E7174777962476865505932675A32394553466A714A6F4375315275706A65385974477173656644323635544D67377573554446647036573145474D63657438'); +INSERT INTO "contacts" VALUES(X'A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1',X'C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1',X'C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2','sent',X'20C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C120C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2C2000000000000C800',NULL,NULL,NULL,NULL,NULL,NULL,1785566240); +INSERT INTO "core_sync_state" VALUES(X'A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1',200,200,NULL); +INSERT INTO "core_transactions" VALUES(X'A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1',X'7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E',200,X'0303030303030303030303030303030303030303030303030303030303030303',1735689600,1,X'0300000000207E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E00000003C8200303030303030303030303030303030303030303030303030303030303030303FC808574670000000000FCE09304000000'); +INSERT INTO "core_utxos" VALUES(X'A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1',X'207E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E7E00',150000,X'76A9148DD22282C2B9245C892BA6143BDAF16D3C0884B088AC',0); +INSERT INTO "identities" VALUES(X'C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1',X'A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1',0,X'20C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C1C12A01010000000000020000000000',0); +INSERT INTO "wallets" VALUES(X'A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1','testnet',100); +PRAGMA foreign_keys=ON; diff --git a/src/wallet_backend/platform_compatibility/mod.rs b/src/wallet_backend/platform_compatibility/mod.rs new file mode 100644 index 000000000..657d05bdb --- /dev/null +++ b/src/wallet_backend/platform_compatibility/mod.rs @@ -0,0 +1,116 @@ +//! Preserve databases created by the pinned Platform PR when opening the development release. + +mod engine; +pub use engine::UpgradeError; + +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; + +use crate::backend_task::error::TaskError; + +pub(crate) fn open(config: SqlitePersisterConfig) -> Result { + let original = match SqlitePersister::open(config.clone()) { + Ok(persister) => return Ok(persister), + Err(error @ WalletStorageError::Migration(_)) => error, + Err(error) => return Err(TaskError::from_wallet_storage_open_error(error)), + }; + let result = + upgrade(&config).map_err(|source| TaskError::PlatformDatabaseUpgrade { source })?; + if !result { + return Err(TaskError::from_wallet_storage_open_error(original)); + } + SqlitePersister::open(config).map_err(TaskError::from_wallet_storage_open_error) +} + +fn upgrade(config: &SqlitePersisterConfig) -> Result { + let parent = config.path.parent().ok_or(UpgradeError::Unrecognized)?; + let mut builder = tempfile::Builder::new(); + builder.prefix("platform-upgrade-"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + builder.permissions(std::fs::Permissions::from_mode(0o700)); + } + let stage_dir = builder.tempdir_in(parent)?; + let stage_path = stage_dir.path().join("wallet.sqlite"); + drop( + SqlitePersister::open(SqlitePersisterConfig::new(&stage_path)) + .map_err(|e| UpgradeError::TypedValidation(Box::new(e)))?, + ); + let backup = engine::upgrade(&config.path, &stage_path, |path| { + let staged = SqlitePersister::open(SqlitePersisterConfig::new(path)) + .map_err(|e| UpgradeError::TypedValidation(Box::new(e)))?; + staged + .load() + .map_err(|e| UpgradeError::TypedValidation(Box::new(e)))?; + staged + .load_unowned_identities() + .map_err(|e| UpgradeError::TypedValidation(Box::new(e)))?; + Ok(()) + })?; + if let Some(backup) = backup { + tracing::info!(backup = %backup.display(), "Upgraded pinned Platform database and retained original backup"); + Ok(true) + } else { + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + + #[test] + fn platform_compatibility_normal_open_upgrades_old_profile() { + let dir = tempfile::tempdir().unwrap(); + crate::app_dir::ensure_data_dir_exists(dir.path()).unwrap(); + let path = dir.path().join("wallet.sqlite"); + rusqlite::Connection::open(&path) + .unwrap() + .execute_batch(include_str!("fixtures/67d4ef3.sql")) + .unwrap(); + rusqlite::Connection::open(&path) + .unwrap() + .execute_batch(include_str!("fixtures/public-rows.sql")) + .unwrap(); + let original_error = match SqlitePersister::open( + SqlitePersisterConfig::new(&path) + .with_auto_backup_dir(Some(dir.path().join("precondition-backup"))), + ) { + Ok(_) => panic!("The old pinned profile unexpectedly opened without compatibility."), + Err(error) => error, + }; + assert!( + matches!(original_error, WalletStorageError::Migration(_)), + "The old profile must reach migration validation, got {original_error:?}." + ); + let persister = open(SqlitePersisterConfig::new(&path)).unwrap(); + let loaded = persister.load().unwrap(); + assert_eq!(loaded.wallets.len(), 1); + let wallet = loaded.wallets.get(&[0xA1; 32]).unwrap(); + assert_eq!(wallet.wallet_info.balance.total(), 150_000); + assert_eq!( + wallet.identity_manager.wallet_identities[&[0xA1; 32]][&0] + .identity + .balance(), + 42 + ); + persister.load_unowned_identities().unwrap(); + let conn = rusqlite::Connection::open(&path).unwrap(); + assert_eq!( + conn.query_row("SELECT count(*) FROM identities", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row("SELECT count(*) FROM core_utxos", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + drop(persister); + open(SqlitePersisterConfig::new(&path)).unwrap(); + } +} diff --git a/src/wallet_backend/snapshot.rs b/src/wallet_backend/snapshot.rs index 1db26ee25..aae9cc3a2 100644 --- a/src/wallet_backend/snapshot.rs +++ b/src/wallet_backend/snapshot.rs @@ -718,6 +718,18 @@ impl SnapshotStore { } } + /// Remove transactions superseded by a final competing spend. + pub(super) fn remove_transactions(&self, wallet_id: &WalletId, txids: &[Txid]) { + let Ok(mut log) = self.tx_log.lock() else { + return; + }; + if let Some(records) = log.get_mut(wallet_id) { + for txid in txids { + records.remove(txid); + } + } + } + /// Seed persisted history without overwriting a record already observed /// live during registration. Later live accumulation replaces by txid. pub(super) fn hydrate_transactions<'a, I>(&self, wallet_id: &WalletId, records: I) From 9cbf464f3b70e17cf56a4a17714ecccfc48653e2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:15:56 +0000 Subject: [PATCH 2/3] fix(deps): pin platform to 63cf57f MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include the pending contact-crypto persistence and identity-removal serialization fix. Migration history and schema remain unchanged; retain the verified database compatibility bridge and other lockfile resolutions. Validation: formatter, 2520 library tests, and CI Clippy flags passed with the locked dependency graph. Co-Authored-By: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- CHANGELOG.md | 2 +- Cargo.lock | 58 +++++++++---------- Cargo.toml | 8 +-- .../2026-09-10-platform-pin/upgrade-notes.md | 12 +++- .../platform_compatibility/fixtures/README.md | 5 +- 5 files changed, 47 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22515e2af..0e563f277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -404,7 +404,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed -- **Platform updated to `4.2.0-dev.8`** (`v4.2-dev`, `e3cd7cf`): existing +- **Platform updated to `4.2.0-dev.8`** (`v4.2-dev`, `63cf57f`): existing databases from the previously pinned PR are upgraded automatically with a retained backup and verified data transfer. Both app preferences and network wallet data are covered. Identity ownership changes preserve saved metadata, diff --git a/Cargo.lock b/Cargo.lock index d06192377..8c6c2a5eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1877,7 +1877,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "dash-platform-macros", "futures-core", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "dash-async" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "futures", "thiserror 2.0.18", @@ -1991,7 +1991,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "dash-async", "dpp", @@ -2101,7 +2101,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "heck", "quote", @@ -2111,7 +2111,7 @@ dependencies = [ [[package]] name = "dash-platform-queries" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "dapi-grpc", "dash-context-provider", @@ -2129,7 +2129,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "arc-swap", "async-trait", @@ -2267,7 +2267,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "platform-value", "platform-version", @@ -2278,7 +2278,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "dashpay-contract", "document-history-contract", @@ -2546,7 +2546,7 @@ dependencies = [ [[package]] name = "document-history-contract" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "platform-value", "platform-version", @@ -2575,7 +2575,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "platform-value", "platform-version", @@ -2586,7 +2586,7 @@ dependencies = [ [[package]] name = "dpp" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "anyhow", "async-trait", @@ -2636,7 +2636,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "proc-macro2", "quote", @@ -2646,7 +2646,7 @@ dependencies = [ [[package]] name = "drive" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2672,7 +2672,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -5072,7 +5072,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "platform-value", "platform-version", @@ -5285,7 +5285,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "platform-value", "platform-version", @@ -6508,7 +6508,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "aes", "cbc", @@ -6521,7 +6521,7 @@ dependencies = [ [[package]] name = "platform-serialization" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6530,7 +6530,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "proc-macro2", "quote", @@ -6541,7 +6541,7 @@ dependencies = [ [[package]] name = "platform-value" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "platform-version" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "bincode 2.0.1", "grovedb-version 5.0.1", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "proc-macro2", "quote", @@ -6582,7 +6582,7 @@ dependencies = [ [[package]] name = "platform-wallet" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "arc-swap", "async-trait", @@ -6616,7 +6616,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "apple-native-keyring-store", "argon2", @@ -7616,7 +7616,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "backon", "chrono", @@ -7642,7 +7642,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "arc-swap", "dash-async", @@ -8883,7 +8883,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "platform-value", "platform-version", @@ -9723,7 +9723,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "platform-value", "platform-version", @@ -11045,7 +11045,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "4.2.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=e3cd7cf5a34dd69b59d633e532a1ac570870b03f#e3cd7cf5a34dd69b59d633e532a1ac570870b03f" +source = "git+https://github.com/dashpay/platform?rev=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index b5c154304..033cb8928 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ eframe = { version = "0.35.0", features = ["persistence", "wgpu"] } base64 = "0.22.1" # TODO: GHSA-7gcf-g7xr-8hxj (serde_with <3.21.0) is unfixable from here — the 2.x pin lives in # dashcore-rpc-json (dashpay/rust-dashcore, rpc-json/Cargo.toml). Re-check when these pins move. -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "e3cd7cf5a34dd69b59d633e532a1ac570870b03f", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "63cf57f40d0000bf3b2b26026c8fa1c71162852d", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -30,15 +30,15 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "e3cd7cf5a34dd69 "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "e3cd7cf5a34dd69b59d633e532a1ac570870b03f" } -platform-wallet = { git = "https://github.com/dashpay/platform", rev = "e3cd7cf5a34dd69b59d633e532a1ac570870b03f", features = [ +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "63cf57f40d0000bf3b2b26026c8fa1c71162852d" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "63cf57f40d0000bf3b2b26026c8fa1c71162852d", features = [ "serde", "shielded", ] } # `serde` backs `model::secret::Secret`'s `Deserialize`: the upstream # visitor copies a borrowed `&str` straight into guarded memory instead of # routing through a transient `String`. Secret schemas are included by `secrets`. -platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "e3cd7cf5a34dd69b59d633e532a1ac570870b03f", features = [ +platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "63cf57f40d0000bf3b2b26026c8fa1c71162852d", features = [ "shielded", "serde", ] } diff --git a/docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md b/docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md index 2a38aca98..106152079 100644 --- a/docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md +++ b/docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md @@ -2,10 +2,16 @@ Reviewed on 2026-09-10. The four Platform dependencies move together from `67d4ef3f6340a1e983229b6870ef60cf7573602a` (`4.2.0-dev.2`, PR #3968) -to `e3cd7cf5a34dd69b59d633e532a1ac570870b03f` (`4.2.0-dev.8`, `v4.2-dev`). +to `63cf57f40d0000bf3b2b26026c8fa1c71162852d` (`4.2.0-dev.8`, `v4.2-dev`). The transitive rust-dashcore revision moves from `3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a` to `93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd`. +The selected revision includes [#4649](https://github.com/dashpay/platform/pull/4649), +which serializes pending contact-crypto persistence with identity removal. +Its changes relative to `e3cd7cf` leave public APIs, dependency manifests, +migration history and the database schema unchanged; the existing `e3cd7cf.sql` +schema guard therefore also applies to `63cf57f`. + ## What survived the PR split | Area | Result at the new pin | @@ -62,9 +68,9 @@ real profile. ## Validation and limits - `cargo fmt --all`: completed. -- `cargo clippy --all-features --all-targets -- -D warnings`: passed with the +- `cargo clippy --locked --all-features --all-targets -- -D warnings`: passed with the exact CI flags. -- `cargo test --lib --all-features`: 2520 passed, none failed or ignored. +- `cargo test --locked --lib --all-features`: 2520 passed, none failed or ignored. This includes old app-preference and populated wallet upgrades, persisted balances/identities, backup preservation, interrupted-process rollback, WAL snapshots, writer exclusion, unknown-schema rejection and repeated open. diff --git a/src/wallet_backend/platform_compatibility/fixtures/README.md b/src/wallet_backend/platform_compatibility/fixtures/README.md index 40c499b5a..4582259f9 100644 --- a/src/wallet_backend/platform_compatibility/fixtures/README.md +++ b/src/wallet_backend/platform_compatibility/fixtures/README.md @@ -17,7 +17,10 @@ store-generation token are deterministic fixture values. The production bridge compares these exact histories and materialized schemas before translating data. The destination itself is created by the current public `SqlitePersister::open`, not by the fixture SQL. Updating -either pinned revision requires reviewing and regenerating these guards. +either schema revision requires reviewing and regenerating these guards. +The selected dependency pin `63cf57f40d0000bf3b2b26026c8fa1c71162852d` +has identical migration code and schema to `e3cd7cf`, so it uses the same +destination guard. `public-rows.sql` contains only public synthetic rows from that old revision's `packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db`, after From 4f5fc0439ec8c3edc58dfd7b69ccb184f6d55912 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:16:46 +0000 Subject: [PATCH 3/3] fix(tests): synchronize network switching and UI fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control the SPV startup future in the cancellation test and separately verify registration remains queued after a fast network switch. Preserve the production network-switch behavior. Wait for the masternode removal result before checking rendered cards, and register the password-prompt fixture after storage preparation. Validation: 2521 library tests, 329 UI tests, 11 E2E tests and 7 doctests passed; formatter and Clippy passed. Network-dependent tests remain ignored. Co-Authored-By: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- src/backend_task/mod.rs | 260 +++++++++++++++++++------------- tests/kittest/masternode_tab.rs | 15 +- tests/kittest/secret_prompt.rs | 46 +++--- 3 files changed, 191 insertions(+), 130 deletions(-) diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7bdd1919b..c46630bc2 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -1204,104 +1204,10 @@ impl AppContext { Ok(BackendTaskSuccessResult::CoreClientReinitialized) } BackendTask::SwitchNetwork { network, start_spv } => { - // Create a new AppContext for the target network, reusing shared - // resources (db, subtasks, connection_status) from the current context. - // Wrapped in block_in_place because AppContext::new() does DB init - // and file I/O which would block the async runtime. - let data_dir = self.data_dir.clone(); - let db = self.db.clone(); - let subtasks = self.subtasks.clone(); - let connection_status = self.connection_status.clone(); - let egui_ctx = self.egui_ctx().clone(); - let app_kv = self.app_kv(); - let secret_store = self.secret_store(); - // Share the app-global role cell so the freshly-switched context - // observes the same value (and live changes) as the rest of the - // app — never a fresh per-context cell. - let user_role = self.user_role_cell(); - let new_ctx = tokio::task::block_in_place(|| { - AppContext::new( - data_dir, - network, - db, - subtasks, - connection_status, - egui_ctx, - app_kv, - secret_store, - user_role, - ) - }) - .ok_or(TaskError::NetworkContextCreationFailed { network })?; - new_ctx.install_secret_prompt(self.secret_prompt()); - - let backend_wired = match new_ctx.ensure_wallet_backend(sender.clone()).await { - Ok(()) => { - if let Err(error) = sender - .send(TaskResult::unattributed_success( - BackendTaskSuccessResult::NetworkContextRegistered { - network, - context: Arc::clone(&new_ctx), - }, - )) - .await - { - tracing::debug!( - ?network, - %error, - "Network switch context registration receiver was unavailable" - ); - } - true - } - Err(error) => { - tracing::warn!( - ?network, - %error, - "Wallet backend wiring failed after network switch" - ); - false - } - }; - - let cancellation_token = self.subtasks.cancellation_token.clone(); - let spv_started = if start_spv - && backend_wired - && !cancellation_token.is_cancelled() - { - tokio::select! { - result = new_ctx.ensure_wallet_backend_and_start_spv(sender.clone()) => { - match result { - Ok(()) => { - tracing::info!(?network, "SPV started after network switch"); - true - } - Err(error) => { - tracing::warn!( - ?network, - %error, - "SPV start failed after network switch" - ); - false - } - } - } - _ = cancellation_token.cancelled() => false, - } - } else { - false - }; - if cancellation_token.is_cancelled() - && let Ok(backend) = new_ctx.wallet_backend() - { - backend.forget_all_secrets(); - backend.shutdown().await; - } - Ok(BackendTaskSuccessResult::NetworkContextCreated { - network, - context: new_ctx, - spv_started, + self.run_switch_network(network, start_spv, sender, |context, sender| async move { + context.ensure_wallet_backend_and_start_spv(sender).await }) + .await } BackendTask::DiscoverDapiNodes { network } => { let devnet_name = self @@ -1324,6 +1230,113 @@ impl AppContext { } } + async fn run_switch_network( + self: &Arc, + network: Network, + start_spv: bool, + sender: SenderAsync, + start_backend: impl FnOnce(Arc, SenderAsync) -> F, + ) -> Result + where + F: Future>, + { + // Create a new AppContext for the target network, reusing shared + // resources (db, subtasks, connection_status) from the current context. + // Wrapped in block_in_place because AppContext::new() does DB init + // and file I/O which would block the async runtime. + let data_dir = self.data_dir.clone(); + let db = self.db.clone(); + let subtasks = self.subtasks.clone(); + let connection_status = self.connection_status.clone(); + let egui_ctx = self.egui_ctx().clone(); + let app_kv = self.app_kv(); + let secret_store = self.secret_store(); + // Share the app-global role cell so the freshly-switched context + // observes the same value (and live changes) as the rest of the + // app — never a fresh per-context cell. + let user_role = self.user_role_cell(); + let new_ctx = tokio::task::block_in_place(|| { + AppContext::new( + data_dir, + network, + db, + subtasks, + connection_status, + egui_ctx, + app_kv, + secret_store, + user_role, + ) + }) + .ok_or(TaskError::NetworkContextCreationFailed { network })?; + new_ctx.install_secret_prompt(self.secret_prompt()); + + let backend_wired = match new_ctx.ensure_wallet_backend(sender.clone()).await { + Ok(()) => { + if let Err(error) = sender + .send(TaskResult::unattributed_success( + BackendTaskSuccessResult::NetworkContextRegistered { + network, + context: Arc::clone(&new_ctx), + }, + )) + .await + { + tracing::debug!( + ?network, + %error, + "Network switch context registration receiver was unavailable" + ); + } + true + } + Err(error) => { + tracing::warn!( + ?network, + %error, + "Wallet backend wiring failed after network switch" + ); + false + } + }; + + let cancellation_token = self.subtasks.cancellation_token.clone(); + let spv_started = if start_spv && backend_wired && !cancellation_token.is_cancelled() { + tokio::select! { + result = start_backend(Arc::clone(&new_ctx), sender.clone()) => { + match result { + Ok(()) => { + tracing::info!(?network, "SPV started after network switch"); + true + } + Err(error) => { + tracing::warn!( + ?network, + %error, + "SPV start failed after network switch" + ); + false + } + } + } + _ = cancellation_token.cancelled() => false, + } + } else { + false + }; + if cancellation_token.is_cancelled() + && let Ok(backend) = new_ctx.wallet_backend() + { + backend.forget_all_secrets(); + backend.shutdown().await; + } + Ok(BackendTaskSuccessResult::NetworkContextCreated { + network, + context: new_ctx, + spv_started, + }) + } + async fn run_wallet_task( self: &Arc, task: WalletTask, @@ -1464,6 +1477,50 @@ mod tests { use super::*; use crate::context::feature_gate::FeatureGate; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn switch_network_keeps_registration_queued_after_completion() { + use crate::context::test_support::test_app_context; + + let dir = tempfile::tempdir().expect("tempdir"); + let context = test_app_context(dir.path()); + let (tx, mut rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, context.egui_ctx().clone()); + let completed = context + .run_backend_task( + BackendTask::SwitchNetwork { + network: Network::Mainnet, + start_spv: false, + }, + sender, + ) + .await + .expect("switch network"); + let BackendTaskSuccessResult::NetworkContextCreated { + context: completed_context, + spv_started, + .. + } = completed + else { + panic!("expected completed network context"); + }; + assert!(!spv_started); + + let mut registered = None; + while let Ok(TaskResult::Success { result, .. }) = rx.try_recv() { + if let BackendTaskSuccessResult::NetworkContextRegistered { context, .. } = *result { + registered = Some(context); + break; + } + } + let registered = registered.expect("registration remains queued after completion"); + assert!(Arc::ptr_eq(®istered, &completed_context)); + registered + .wallet_backend() + .expect("registered backend") + .shutdown() + .await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn switch_network_registers_wired_backend_before_cancellation_teardown() { use crate::context::test_support::test_app_context; @@ -1474,12 +1531,11 @@ mod tests { let context = test_app_context(temp_dir.path()); let (tx, mut rx) = tokio::sync::mpsc::channel::(32); let sender = SenderAsync::new(tx, context.egui_ctx().clone()); - let mut switch = Box::pin(context.run_backend_task( - BackendTask::SwitchNetwork { - network: Network::Mainnet, - start_spv: true, - }, + let mut switch = Box::pin(context.run_switch_network( + Network::Mainnet, + true, sender, + |_, _| std::future::pending(), )); let registered_context = tokio::time::timeout(Duration::from_secs(5), async { diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index 1c26c2655..543e13c56 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -686,7 +686,20 @@ fn remove_flow_deletes_only_target_node() { .last() .expect("confirm button present"); confirm.click(); - harness.run_steps(3); + // Removal runs on the backend. Wait for its result to navigate back + // to the list before checking the cards, not merely for the DB write. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + harness.step(); + if harness.query_by_label("Open mn-keep-me").is_some() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "removal must return to the masternode list" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } // Only the target node was deleted; the other remains (isolation). let remaining = app_context diff --git a/tests/kittest/secret_prompt.rs b/tests/kittest/secret_prompt.rs index 4deb087c0..d4bc3dd3a 100644 --- a/tests/kittest/secret_prompt.rs +++ b/tests/kittest/secret_prompt.rs @@ -9,9 +9,6 @@ //! and toggles (unchecked maps to `RememberPolicy::None`, checked to //! `UntilAppClose` — the mapping itself is unit-tested in //! `secret_prompt_host`). -//! -//! NOTE: the kittest suite has pre-existing `DivergentVersion` failures -//! unrelated to this module. use std::cell::Cell; use std::rc::Rc; @@ -32,8 +29,6 @@ use dash_evo_tool::model::secret::Secret; use dash_evo_tool::model::wallet::Wallet; #[cfg(feature = "testing")] use dash_evo_tool::model::wallet::birth_height::WalletOrigin; -#[cfg(feature = "testing")] -use dash_sdk::dpp::dashcore::Network; /// The modal renders the scope body, the hint, the retry error, and the /// remember checkbox. @@ -416,34 +411,31 @@ fn appstate_migration_prompt_activation_drops_transition_frame_click() { let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); let _guard = rt.enter(); - let seed_hash = Rc::new(Cell::new([0; 32])); - let seed_hash_for_app = Rc::clone(&seed_hash); let mut harness = Harness::builder() .with_max_steps(100) .build_eframe(move |ctx| { - let app = dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) .expect("Failed to create AppState") - .with_animations(false); - - let password = Secret::new("correct password"); - let seed = [0xA7; 64]; - let wallet = Wallet::new_from_seed( - seed, - Network::Testnet, - Some("Savings".to_string()), - Some(&password), - ) - .expect("build protected wallet"); - let (seed_hash, wallet) = app - .current_app_context() - .register_wallet(wallet, &seed, WalletOrigin::Imported) - .expect("register protected wallet fixture"); - wallet.write().expect("wallet lock").wallet_seed.close(); - seed_hash_for_app.set(seed_hash); - app + .with_animations(false) }); harness.set_size(egui::vec2(1024.0, 768.0)); let app_context = crate::support::wait_for_wallet_backend(&mut harness); + + // This test activates the prompt explicitly. Seed its wallet only after + // boot so background wiring cannot also request the fixture's password. + let password = Secret::new("correct password"); + let seed = [0xA7; 64]; + let wallet = Wallet::new_from_seed( + seed, + app_context.network(), + Some("Savings".to_string()), + Some(&password), + ) + .expect("build protected wallet"); + let (seed_hash, wallet) = app_context + .register_wallet(wallet, &seed, WalletOrigin::Imported) + .expect("register protected wallet fixture"); + wallet.write().expect("wallet lock").wallet_seed.close(); harness.run_steps(5); let card_center = harness.get_by_label("Just Explore").rect().center(); @@ -459,7 +451,7 @@ fn appstate_migration_prompt_activation_drops_transition_frame_click() { app_context .migration_status() .set_state(MigrationState::AwaitingWalletPasswords { - wallets: vec![seed_hash.get()], + wallets: vec![seed_hash], }); harness.event(egui::Event::PointerButton { pos: card_center,