diff --git a/CHANGELOG.md b/CHANGELOG.md index af7ae5630..0e563f277 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`, `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, + 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..8c6c2a5eb 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" 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=63cf57f40d0000bf3b2b26026c8fa1c71162852d#63cf57f40d0000bf3b2b26026c8fa1c71162852d" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index c5a7e02b2..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 = "67d4ef3f6340a1e983229b6870ef60cf7573602a", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "63cf57f40d0000bf3b2b26026c8fa1c71162852d", 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 = "63cf57f40d0000bf3b2b26026c8fa1c71162852d" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "63cf57f40d0000bf3b2b26026c8fa1c71162852d", 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 = "63cf57f40d0000bf3b2b26026c8fa1c71162852d", 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..106152079 --- /dev/null +++ b/docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md @@ -0,0 +1,91 @@ +# 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 `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 | +| --- | --- | +| 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 --locked --all-features --all-targets -- -D warnings`: passed with the + exact CI flags. +- `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. +- 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/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/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..4582259f9 --- /dev/null +++ b/src/wallet_backend/platform_compatibility/fixtures/README.md @@ -0,0 +1,30 @@ +# 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 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 +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) 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,