diff --git a/.pnp.cjs b/.pnp.cjs index 8d3d5332a00..3bb1467bbb3 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -2561,7 +2561,7 @@ const RAW_RUNTIME_STATE = ["@dashevo/dapi-client", "workspace:packages/js-dapi-client"],\ ["@dashevo/dapi-grpc", "workspace:packages/dapi-grpc"],\ ["@dashevo/dashcore-lib", "npm:0.22.0"],\ - ["@dashevo/dashd-rpc", "npm:19.0.0"],\ + ["@dashevo/dashd-rpc", "npm:19.1.0"],\ ["@dashevo/dp-services-ctl", "https://github.com/dashevo/js-dp-services-ctl.git#commit=3976076b0018c5b4632ceda4c752fc597f27a640"],\ ["@dashevo/grpc-common", "workspace:packages/js-grpc-common"],\ ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ @@ -2732,10 +2732,10 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@dashevo/dashd-rpc", [\ - ["npm:19.0.0", {\ - "packageLocation": "./.yarn/cache/@dashevo-dashd-rpc-npm-19.0.0-54bb2a5dfc-2eab84af3e.zip/node_modules/@dashevo/dashd-rpc/",\ + ["npm:19.1.0", {\ + "packageLocation": "./.yarn/cache/@dashevo-dashd-rpc-npm-19.1.0-9dfdfd28d7-5d3ebf1f0b.zip/node_modules/@dashevo/dashd-rpc/",\ "packageDependencies": [\ - ["@dashevo/dashd-rpc", "npm:19.0.0"],\ + ["@dashevo/dashd-rpc", "npm:19.1.0"],\ ["async", "npm:3.2.4"],\ ["bluebird", "npm:3.7.2"]\ ],\ @@ -9702,7 +9702,7 @@ const RAW_RUNTIME_STATE = ["@dashevo/bls", "npm:1.2.9"],\ ["@dashevo/dapi-client", "workspace:packages/js-dapi-client"],\ ["@dashevo/dashcore-lib", "npm:0.22.0"],\ - ["@dashevo/dashd-rpc", "npm:19.0.0"],\ + ["@dashevo/dashd-rpc", "npm:19.1.0"],\ ["@dashevo/docker-compose", "npm:0.24.4"],\ ["@dashevo/wallet-lib", "workspace:packages/wallet-lib"],\ ["@dashevo/withdrawals-contract", "workspace:packages/withdrawals-contract"],\ diff --git a/.yarn/cache/@dashevo-dashd-rpc-npm-19.0.0-54bb2a5dfc-2eab84af3e.zip b/.yarn/cache/@dashevo-dashd-rpc-npm-19.0.0-54bb2a5dfc-2eab84af3e.zip deleted file mode 100644 index 6ad3f917538..00000000000 Binary files a/.yarn/cache/@dashevo-dashd-rpc-npm-19.0.0-54bb2a5dfc-2eab84af3e.zip and /dev/null differ diff --git a/.yarn/cache/@dashevo-dashd-rpc-npm-19.1.0-9dfdfd28d7-5d3ebf1f0b.zip b/.yarn/cache/@dashevo-dashd-rpc-npm-19.1.0-9dfdfd28d7-5d3ebf1f0b.zip new file mode 100644 index 00000000000..8c470f8b74e Binary files /dev/null and b/.yarn/cache/@dashevo-dashd-rpc-npm-19.1.0-9dfdfd28d7-5d3ebf1f0b.zip differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 873df2944a5..4667d73c86a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,28 @@ `serialization::dashcore::bls_pubkey` Serde adapter remains a deprecated alias. Serialized encodings and consensus rules are unchanged. See [#5320](https://github.com/dashpay/platform/pull/5320). +## [5.0.0-rc.4](https://github.com/dashpay/platform/compare/v5.0.0-rc.3...v5.0.0-rc.4) (2026-10-17) + + +### ⚠ BREAKING CHANGES + +* **drive-abci:** keep the operator identity when Core re-encodes the same operator key (#5461) +* **dpp:** refuse hash key data that is not 20 bytes at protocol version 14 (#5463) + +### Bug Fixes + +* **dashmate:** show platform status on Dash Core v24 ([#5458](https://github.com/dashpay/platform/issues/5458)) +* **dpp:** refuse hash key data that is not 20 bytes at protocol version 14 ([#5463](https://github.com/dashpay/platform/issues/5463)) +* **drive-abci:** keep the operator identity when Core re-encodes the same operator key ([#5461](https://github.com/dashpay/platform/issues/5461)) +* **drive:** add masternode identity keys through a dedicated operation ([#5456](https://github.com/dashpay/platform/issues/5456)) +* **platform-wallet:** find a masternode's operator identity after Core re-encodes its operator key ([#5462](https://github.com/dashpay/platform/issues/5462)) + + +### Continuous Integration + +* fall back to GitHub-hosted runners when DASH_LIGHT_RUNNER is unset ([#5469](https://github.com/dashpay/platform/issues/5469)) +* run short PR jobs on self-hosted light runners ([#5468](https://github.com/dashpay/platform/issues/5468)) + ## [5.0.0-rc.3](https://github.com/dashpay/platform/compare/v5.0.0-rc.2...v5.0.0-rc.3) (2026-10-15) diff --git a/Cargo.lock b/Cargo.lock index 1a276acb24e..9d36bc8fc09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -183,7 +183,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app-connect-contract" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "base58", "platform-value", @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "check-features" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "toml 0.8.23", ] @@ -1709,7 +1709,7 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "bytes", "dash-platform-macros", @@ -1801,7 +1801,7 @@ dependencies = [ [[package]] name = "dash-async" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "futures", "thiserror 2.0.18", @@ -1812,7 +1812,7 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "dash-async", "dpp", @@ -1880,7 +1880,7 @@ dependencies = [ [[package]] name = "dash-platform-balance-checker" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "anyhow", "clap", @@ -1895,7 +1895,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "heck 0.5.0", "quote", @@ -1904,7 +1904,7 @@ dependencies = [ [[package]] name = "dash-platform-queries" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "dapi-grpc", "dash-context-provider", @@ -1921,7 +1921,7 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "arc-swap", "assert_matches", @@ -2116,7 +2116,7 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "platform-value", "platform-version", @@ -2126,7 +2126,7 @@ dependencies = [ [[package]] name = "data-contracts" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "app-connect-contract", "base58", @@ -2341,7 +2341,7 @@ dependencies = [ [[package]] name = "document-history-contract" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "platform-value", "platform-version", @@ -2363,7 +2363,7 @@ checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" [[package]] name = "dpns-contract" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "platform-value", "platform-version", @@ -2373,7 +2373,7 @@ dependencies = [ [[package]] name = "dpp" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "anyhow", "assert_matches", @@ -2438,7 +2438,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "proc-macro2", "quote", @@ -2447,7 +2447,7 @@ dependencies = [ [[package]] name = "drive" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "arc-swap", "assert_matches", @@ -2490,7 +2490,7 @@ dependencies = [ [[package]] name = "drive-abci" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "arc-swap", "assert_matches", @@ -2557,7 +2557,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "dapi-grpc", "dash-context-provider", @@ -4371,7 +4371,7 @@ dependencies = [ [[package]] name = "json-schema-compatibility-validator" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "assert_matches", "json-patch", @@ -4500,7 +4500,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "base58", "platform-value", @@ -4691,7 +4691,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "platform-value", "platform-version", @@ -4880,7 +4880,7 @@ dependencies = [ [[package]] name = "moderation-charters-contract" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "base58", "platform-value", @@ -5435,7 +5435,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "platform-encryption" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "aes 0.8.4", "cbc", @@ -5447,7 +5447,7 @@ dependencies = [ [[package]] name = "platform-serialization" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "grovedb-bincode", "platform-version", @@ -5455,7 +5455,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "proc-macro2", "quote", @@ -5465,7 +5465,7 @@ dependencies = [ [[package]] name = "platform-value" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "base64 0.22.1", "bs58", @@ -5484,7 +5484,7 @@ dependencies = [ [[package]] name = "platform-value-convertible" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "quote", "syn 2.0.117", @@ -5492,7 +5492,7 @@ dependencies = [ [[package]] name = "platform-version" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "grovedb-bincode", "grovedb-version", @@ -5502,7 +5502,7 @@ dependencies = [ [[package]] name = "platform-versioning" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "proc-macro2", "quote", @@ -5511,7 +5511,7 @@ dependencies = [ [[package]] name = "platform-wallet" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "arc-swap", "async-trait", @@ -5552,7 +5552,7 @@ dependencies = [ [[package]] name = "platform-wallet-ffi" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "anyhow", "async-trait", @@ -5580,7 +5580,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "apple-native-keyring-store", "argon2", @@ -6588,7 +6588,7 @@ dependencies = [ [[package]] name = "rs-dapi" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "async-trait", "axum 0.8.9", @@ -6642,7 +6642,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "backon", "chrono", @@ -6673,7 +6673,7 @@ dependencies = [ [[package]] name = "rs-dash-event-bus" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "metrics", "tokio", @@ -6706,7 +6706,7 @@ dependencies = [ [[package]] name = "rs-sdk-ffi" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "async-trait", "bs58", @@ -6740,7 +6740,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "arc-swap", "dash-async", @@ -6763,7 +6763,7 @@ dependencies = [ [[package]] name = "rs-unified-sdk-ffi" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "dash-network", "key-wallet-ffi", @@ -6773,7 +6773,7 @@ dependencies = [ [[package]] name = "rs-unified-sdk-jni" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "android_logger", "dash-network", @@ -7482,7 +7482,7 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "simple-signer" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "async-trait", "base64 0.22.1", @@ -7603,7 +7603,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strategy-tests" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "dpp", "drive", @@ -8005,7 +8005,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "platform-value", "platform-version", @@ -8836,7 +8836,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "platform-value", "platform-version", @@ -8979,7 +8979,7 @@ checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea" [[package]] name = "wasm-dpp" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "anyhow", "async-trait", @@ -9003,7 +9003,7 @@ dependencies = [ [[package]] name = "wasm-dpp2" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "anyhow", "async-trait", @@ -9022,7 +9022,7 @@ dependencies = [ [[package]] name = "wasm-drive-verify" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "base64 0.22.1", "bs58", @@ -9077,7 +9077,7 @@ dependencies = [ [[package]] name = "wasm-sdk" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "async-trait", "base64 0.22.1", @@ -9591,7 +9591,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index cd9d3a1cdc7..3d099887b8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,5 +149,5 @@ opt-level = 3 [workspace.package] -version = "5.0.0-rc.3" +version = "5.0.0-rc.4" rust-version = "1.98" diff --git a/package.json b/package.json index 54cbeccdfeb..215b16ec076 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/platform", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "private": true, "scripts": { "setup": "yarn install && yarn run build && yarn run configure", diff --git a/packages/app-connect-contract/package.json b/packages/app-connect-contract/package.json index 4e340d14aa5..b0c8fd8ee48 100644 --- a/packages/app-connect-contract/package.json +++ b/packages/app-connect-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/app-connect-contract", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "A system contract for encrypted wallet-to-app login responses", "scripts": { "lint": "eslint .", diff --git a/packages/bench-suite/package.json b/packages/bench-suite/package.json index 18f9369ada1..d38881ca091 100644 --- a/packages/bench-suite/package.json +++ b/packages/bench-suite/package.json @@ -1,7 +1,7 @@ { "name": "@dashevo/bench-suite", "private": true, - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "Dash Platform benchmark tool", "engines": { "node": ">=22" diff --git a/packages/dapi-grpc/package.json b/packages/dapi-grpc/package.json index 94690a3a115..004ad048487 100644 --- a/packages/dapi-grpc/package.json +++ b/packages/dapi-grpc/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dapi-grpc", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "DAPI GRPC definition file and generated clients", "browser": "browser.js", "main": "node.js", diff --git a/packages/dapi/package.json b/packages/dapi/package.json index 9e537957e47..9d926becfff 100644 --- a/packages/dapi/package.json +++ b/packages/dapi/package.json @@ -1,7 +1,7 @@ { "name": "@dashevo/dapi", "private": true, - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "A decentralized API for the Dash network", "engines": { "node": ">=22" @@ -39,7 +39,7 @@ "@dashevo/bls": "~1.2.9", "@dashevo/dapi-grpc": "workspace:*", "@dashevo/dashcore-lib": "~0.22.0", - "@dashevo/dashd-rpc": "^19.0.0", + "@dashevo/dashd-rpc": "^19.1.0", "@dashevo/grpc-common": "workspace:*", "@dashevo/wasm-dpp": "workspace:*", "@grpc/grpc-js": "^1.14.3", diff --git a/packages/dash-spv/package.json b/packages/dash-spv/package.json index d7b7224e99c..b1865e17619 100644 --- a/packages/dash-spv/package.json +++ b/packages/dash-spv/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dash-spv", - "version": "6.0.0-rc.3", + "version": "6.0.0-rc.4", "description": "Repository containing SPV functions used by @dashevo", "main": "index.js", "scripts": { diff --git a/packages/dashmate/package.json b/packages/dashmate/package.json index 7dac0d04a2f..f1dbba928e3 100644 --- a/packages/dashmate/package.json +++ b/packages/dashmate/package.json @@ -1,6 +1,6 @@ { "name": "dashmate", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "Distribution package for Dash node installation", "scripts": { "lint": "eslint .", @@ -59,7 +59,7 @@ "@dashevo/bls": "~1.2.9", "@dashevo/dapi-client": "workspace:*", "@dashevo/dashcore-lib": "~0.22.0", - "@dashevo/dashd-rpc": "^19.0.0", + "@dashevo/dashd-rpc": "^19.1.0", "@dashevo/docker-compose": "^0.24.4", "@dashevo/wallet-lib": "workspace:*", "@dashevo/withdrawals-contract": "workspace:*", diff --git a/packages/dashmate/src/status/scopes/platform.js b/packages/dashmate/src/status/scopes/platform.js index b2f44b8eac3..4e3b565e197 100644 --- a/packages/dashmate/src/status/scopes/platform.js +++ b/packages/dashmate/src/status/scopes/platform.js @@ -49,11 +49,19 @@ export default function getPlatformScopeFactory( rpcClient.getBlockchainInfo(), ]); - const { - result: { - softforks: { mn_rr: mnRR }, - }, - } = blockchainInfo; + // Core v24 omits softforks from getblockchaininfo unless started with + // -deprecatedrpc=softforks and reports them in getdeploymentinfo. Core v23 has no + // getdeploymentinfo, so softforks is read first. + const deployments = blockchainInfo.result.softforks + ?? (await rpcClient.getDeploymentInfo()).result?.deployments; + + const mnRR = deployments?.mn_rr; + + // getPlatformScope reads the mn_rr deployment outside its try, so a missing or malformed + // deployment has to fail here, where it is handled as unavailable Core info. + if (typeof mnRR?.active !== 'boolean' || !Number.isInteger(mnRR.height)) { + throw new Error(`Core did not report a valid mn_rr deployment: ${JSON.stringify(mnRR)}`); + } const { result: { @@ -336,7 +344,7 @@ export default function getPlatformScopeFactory( } catch (e) { if (process.env.DEBUG) { // eslint-disable-next-line no-console - console.error('Could not get MNSync from core', e); + console.error('Could not get sync status and platform activation from Core', e); } } diff --git a/packages/dashmate/test/fixtures/coreRpc/23.1.8/getblockchaininfo-1112.json b/packages/dashmate/test/fixtures/coreRpc/23.1.8/getblockchaininfo-1112.json new file mode 100644 index 00000000000..e164a8fdffb --- /dev/null +++ b/packages/dashmate/test/fixtures/coreRpc/23.1.8/getblockchaininfo-1112.json @@ -0,0 +1,117 @@ +{ + "chain": "regtest", + "blocks": 1112, + "headers": 1112, + "bestblockhash": "0933f86094f3ded670c18238e70a8eacb06813373657d10e328dba2a31c7f6cd", + "difficulty": 4.656542373906925e-10, + "time": 1792060531, + "mediantime": 1792060530, + "verificationprogress": 1, + "initialblockdownload": false, + "chainwork": "00000000000000000000000000000000000000000000000000000000000008b2", + "size_on_disk": 802296, + "pruned": false, + "softforks": { + "bip34": { + "type": "buried", + "active": true, + "height": 1 + }, + "bip66": { + "type": "buried", + "active": true, + "height": 1 + }, + "bip65": { + "type": "buried", + "active": true, + "height": 1 + }, + "bip147": { + "type": "buried", + "active": true, + "height": 0 + }, + "csv": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0001": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0003": { + "type": "buried", + "active": true, + "height": 432 + }, + "dip0008": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0020": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0024": { + "type": "buried", + "active": true, + "height": 1 + }, + "realloc": { + "type": "buried", + "active": true, + "height": 1 + }, + "v19": { + "type": "buried", + "active": true, + "height": 1 + }, + "v20": { + "type": "buried", + "active": true, + "height": 432 + }, + "mn_rr": { + "type": "buried", + "active": true, + "height": 1000 + }, + "withdrawals": { + "type": "buried", + "active": true, + "height": 600 + }, + "v24": { + "type": "bip9", + "bip9": { + "status": "defined", + "start_time": 0, + "timeout": 9223372036854775807, + "min_activation_height": 0, + "ehf": true, + "since": 0 + }, + "active": false + }, + "testdummy": { + "type": "bip9", + "bip9": { + "status": "active", + "start_time": 0, + "timeout": 9223372036854775807, + "min_activation_height": 0, + "ehf": false, + "since": 432 + }, + "height": 432, + "active": true + } + }, + "warnings": "" +} diff --git a/packages/dashmate/test/fixtures/coreRpc/23.1.8/mnsync-status.json b/packages/dashmate/test/fixtures/coreRpc/23.1.8/mnsync-status.json new file mode 100644 index 00000000000..d587204ecbd --- /dev/null +++ b/packages/dashmate/test/fixtures/coreRpc/23.1.8/mnsync-status.json @@ -0,0 +1,8 @@ +{ + "AssetID": 999, + "AssetName": "MASTERNODE_SYNC_FINISHED", + "AssetStartTime": 1792060356, + "Attempt": 0, + "IsBlockchainSynced": true, + "IsSynced": true +} diff --git a/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getblockchaininfo-1112.json b/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getblockchaininfo-1112.json new file mode 100644 index 00000000000..89d4a6a9537 --- /dev/null +++ b/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getblockchaininfo-1112.json @@ -0,0 +1,15 @@ +{ + "chain": "regtest", + "blocks": 1112, + "headers": 1112, + "bestblockhash": "69bf772133b0ed53979dce7927104fcc3577180137aacb283e8a282ecb86bf85", + "difficulty": 4.656542373906925e-10, + "time": 1792060432, + "mediantime": 1792060431, + "verificationprogress": 1, + "initialblockdownload": false, + "chainwork": "00000000000000000000000000000000000000000000000000000000000008b2", + "size_on_disk": 802291, + "pruned": false, + "warnings": "This is a pre-release test build - use at your own risk - do not use for mining or merchant applications" +} diff --git a/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getblockchaininfo-512.json b/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getblockchaininfo-512.json new file mode 100644 index 00000000000..8f30b16fc0e --- /dev/null +++ b/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getblockchaininfo-512.json @@ -0,0 +1,15 @@ +{ + "chain": "regtest", + "blocks": 512, + "headers": 512, + "bestblockhash": "0883b85d67c2eaf7d5756fd174cb79e99144bcab29a56b2a122b2134203ab45d", + "difficulty": 4.656542373906925e-10, + "time": 1792060332, + "mediantime": 1792060331, + "verificationprogress": 1, + "initialblockdownload": false, + "chainwork": "0000000000000000000000000000000000000000000000000000000000000402", + "size_on_disk": 172286, + "pruned": false, + "warnings": "This is a pre-release test build - use at your own risk - do not use for mining or merchant applications" +} diff --git a/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getdeploymentinfo-1112.json b/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getdeploymentinfo-1112.json new file mode 100644 index 00000000000..583c7c69aa4 --- /dev/null +++ b/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getdeploymentinfo-1112.json @@ -0,0 +1,121 @@ +{ + "hash": "69bf772133b0ed53979dce7927104fcc3577180137aacb283e8a282ecb86bf85", + "height": 1112, + "deployments": { + "bip34": { + "type": "buried", + "active": true, + "height": 1 + }, + "bip66": { + "type": "buried", + "active": true, + "height": 1 + }, + "bip65": { + "type": "buried", + "active": true, + "height": 1 + }, + "bip147": { + "type": "buried", + "active": true, + "height": 0 + }, + "csv": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0001": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0003": { + "type": "buried", + "active": true, + "height": 432 + }, + "dip0008": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0020": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0024": { + "type": "buried", + "active": true, + "height": 1 + }, + "realloc": { + "type": "buried", + "active": true, + "height": 1 + }, + "v19": { + "type": "buried", + "active": true, + "height": 1 + }, + "v20": { + "type": "buried", + "active": true, + "height": 432 + }, + "mn_rr": { + "type": "buried", + "active": true, + "height": 1000 + }, + "withdrawals": { + "type": "buried", + "active": true, + "height": 600 + }, + "v24": { + "type": "bip9", + "active": false, + "bip9": { + "start_time": 0, + "timeout": 9223372036854775807, + "ehf": true, + "min_activation_height": 0, + "status": "defined", + "since": 0, + "status_next": "defined" + } + }, + "evo_shares": { + "type": "bip9", + "active": false, + "bip9": { + "start_time": 0, + "timeout": 9223372036854775807, + "ehf": true, + "min_activation_height": 0, + "status": "defined", + "since": 0, + "status_next": "defined" + } + }, + "testdummy": { + "type": "bip9", + "height": 432, + "active": true, + "bip9": { + "start_time": 0, + "timeout": 9223372036854775807, + "ehf": false, + "min_activation_height": 0, + "status": "active", + "since": 432, + "status_next": "active" + } + } + } +} diff --git a/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getdeploymentinfo-512.json b/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getdeploymentinfo-512.json new file mode 100644 index 00000000000..5e99145c10f --- /dev/null +++ b/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/getdeploymentinfo-512.json @@ -0,0 +1,121 @@ +{ + "hash": "0883b85d67c2eaf7d5756fd174cb79e99144bcab29a56b2a122b2134203ab45d", + "height": 512, + "deployments": { + "bip34": { + "type": "buried", + "active": true, + "height": 1 + }, + "bip66": { + "type": "buried", + "active": true, + "height": 1 + }, + "bip65": { + "type": "buried", + "active": true, + "height": 1 + }, + "bip147": { + "type": "buried", + "active": true, + "height": 0 + }, + "csv": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0001": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0003": { + "type": "buried", + "active": true, + "height": 432 + }, + "dip0008": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0020": { + "type": "buried", + "active": true, + "height": 1 + }, + "dip0024": { + "type": "buried", + "active": true, + "height": 1 + }, + "realloc": { + "type": "buried", + "active": true, + "height": 1 + }, + "v19": { + "type": "buried", + "active": true, + "height": 1 + }, + "v20": { + "type": "buried", + "active": true, + "height": 432 + }, + "mn_rr": { + "type": "buried", + "active": false, + "height": 1000 + }, + "withdrawals": { + "type": "buried", + "active": false, + "height": 600 + }, + "v24": { + "type": "bip9", + "active": false, + "bip9": { + "start_time": 0, + "timeout": 9223372036854775807, + "ehf": true, + "min_activation_height": 0, + "status": "defined", + "since": 0, + "status_next": "defined" + } + }, + "evo_shares": { + "type": "bip9", + "active": false, + "bip9": { + "start_time": 0, + "timeout": 9223372036854775807, + "ehf": true, + "min_activation_height": 0, + "status": "defined", + "since": 0, + "status_next": "defined" + } + }, + "testdummy": { + "type": "bip9", + "height": 432, + "active": true, + "bip9": { + "start_time": 0, + "timeout": 9223372036854775807, + "ehf": false, + "min_activation_height": 0, + "status": "active", + "since": 432, + "status_next": "active" + } + } + } +} diff --git a/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/mnsync-status.json b/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/mnsync-status.json new file mode 100644 index 00000000000..4643b091c2e --- /dev/null +++ b/packages/dashmate/test/fixtures/coreRpc/24.0.0-rc.3/mnsync-status.json @@ -0,0 +1,8 @@ +{ + "AssetID": 999, + "AssetName": "MASTERNODE_SYNC_FINISHED", + "AssetStartTime": 1792060306, + "Attempt": 0, + "IsBlockchainSynced": true, + "IsSynced": true +} diff --git a/packages/dashmate/test/unit/core/createRpcClient.spec.js b/packages/dashmate/test/unit/core/createRpcClient.spec.js new file mode 100644 index 00000000000..cad7b88e2ad --- /dev/null +++ b/packages/dashmate/test/unit/core/createRpcClient.spec.js @@ -0,0 +1,42 @@ +import { EventEmitter } from 'events'; +import fs from 'fs'; +import http from 'http'; +import path from 'path'; +import { PACKAGE_ROOT_DIR } from '../../../src/constants.js'; +import createRpcClient from '../../../src/core/createRpcClient.js'; + +describe('createRpcClient', () => { + it('should call getdeploymentinfo, where Core v24 reports platform activation', async function it() { + // Recorded from Dash Core 24.0.0-rc.3 on regtest with testactivationheight=mn_rr@1000 + const deploymentInfo = JSON.parse(fs.readFileSync( + path.join(PACKAGE_ROOT_DIR, 'test/fixtures/coreRpc/24.0.0-rc.3/getdeploymentinfo-1112.json'), + 'utf8', + )); + + let requestBody = ''; + + this.sinon.stub(http, 'request').callsFake((options, callback) => { + const request = new EventEmitter(); + request.setHeader = () => {}; + request.write = (data) => { + requestBody += data; + }; + request.end = () => { + const response = new EventEmitter(); + callback(response); + response.statusCode = 200; + response.emit('data', JSON.stringify({ result: deploymentInfo, error: null, id: 1 })); + response.emit('end'); + }; + + return request; + }); + + const { result } = await createRpcClient().getDeploymentInfo(); + + const { method, params } = JSON.parse(requestBody); + expect(method).to.equal('getdeploymentinfo'); + expect(params).to.deep.equal([]); + expect(result.deployments.mn_rr).to.deep.equal({ type: 'buried', active: true, height: 1000 }); + }); +}); diff --git a/packages/dashmate/test/unit/status/scopes/platform.spec.js b/packages/dashmate/test/unit/status/scopes/platform.spec.js index b6736845298..f34ffb14188 100644 --- a/packages/dashmate/test/unit/status/scopes/platform.spec.js +++ b/packages/dashmate/test/unit/status/scopes/platform.spec.js @@ -1,3 +1,6 @@ +import fs from 'fs'; +import path from 'path'; +import { PACKAGE_ROOT_DIR } from '../../../../src/constants.js'; import ContainerIsNotPresentError from '../../../../src/docker/errors/ContainerIsNotPresentError.js'; import DockerComposeError from '../../../../src/docker/errors/DockerComposeError.js'; @@ -33,6 +36,7 @@ describe('getPlatformScopeFactory', () => { mockRpcClient = { mnsync: this.sinon.stub(), getBlockchainInfo: this.sinon.stub(), + getDeploymentInfo: this.sinon.stub(), masternode: this.sinon.stub(), }; mockDockerCompose = { @@ -746,5 +750,113 @@ describe('getPlatformScopeFactory', () => { const scope = await getPlatformScope(config); expect(scope).to.deep.equal(expectedScope); }); + + describe('platform activation', () => { + /** + * Read a Core RPC answer recorded from an official Dash Core build on regtest with + * testactivationheight=mn_rr@1000 and without -deprecatedrpc=softforks, at height 512 + * (before mn_rr) or 1112 (after it). Every call parses the file again, so a test may + * change the answer it gets. + * + * @param {string} coreVersion + * @param {string} name + * @returns {{result: Object}} + */ + function readCoreAnswer(coreVersion, name) { + const file = path.join(PACKAGE_ROOT_DIR, 'test/fixtures/coreRpc', coreVersion, `${name}.json`); + + return { result: JSON.parse(fs.readFileSync(file, 'utf8')) }; + } + + beforeEach(() => { + mockDockerCompose.isServiceRunning.withArgs(config, 'drive_tenderdash').returns(false); + mockDetermineDockerStatus.withArgs(mockDockerCompose, config, 'drive_abci') + .returns(DockerStatusEnum.running); + mockDockerCompose.execCommand.withArgs(config, 'drive_abci', 'drive-abci status').resolves({ exitCode: 0, out: '' }); + mockDockerCompose.execCommand.withArgs(config, 'drive_abci', 'drive-abci version').resolves({ exitCode: 0, out: '1.4.1' }); + }); + + [ + { + height: 1112, + platformActivation: 'Activated (at height 1000)', + driveServiceStatus: ServiceStatusEnum.up, + }, + { + height: 512, + platformActivation: 'Waiting for activation on height 1000', + driveServiceStatus: ServiceStatusEnum.wait_for_activation, + }, + ].forEach(({ height, platformActivation, driveServiceStatus }) => { + it(`should report platform activation and status at height ${height} on Core v24, which by default no longer reports softforks in getblockchaininfo`, async () => { + mockRpcClient.mnsync.withArgs('status').returns(readCoreAnswer('24.0.0-rc.3', 'mnsync-status')); + mockRpcClient.getBlockchainInfo.returns(readCoreAnswer('24.0.0-rc.3', `getblockchaininfo-${height}`)); + mockRpcClient.getDeploymentInfo.returns(readCoreAnswer('24.0.0-rc.3', `getdeploymentinfo-${height}`)); + + const scope = await getPlatformScope(config); + + expect(scope.platformActivation).to.equal(platformActivation); + expect(scope.coreIsSynced).to.be.true(); + expect(scope.tenderdash.serviceStatus).to.equal(ServiceStatusEnum.stopped); + expect(scope.drive).to.deep.equal({ + dockerStatus: DockerStatusEnum.running, + serviceStatus: driveServiceStatus, + version: '1.4.1', + }); + }); + }); + + it('should read platform activation from getblockchaininfo softforks on Core v23 without calling getdeploymentinfo', async () => { + // Core v23 has no getdeploymentinfo and reports deployments only in getblockchaininfo softforks + mockRpcClient.mnsync.withArgs('status').returns(readCoreAnswer('23.1.8', 'mnsync-status')); + mockRpcClient.getBlockchainInfo.returns(readCoreAnswer('23.1.8', 'getblockchaininfo-1112')); + + const scope = await getPlatformScope(config); + + expect(scope.platformActivation).to.equal('Activated (at height 1000)'); + expect(scope.coreIsSynced).to.be.true(); + expect(scope.drive.serviceStatus).to.equal(ServiceStatusEnum.up); + expect(mockRpcClient.getDeploymentInfo).to.not.have.been.called(); + }); + + [ + { + coreVersion: '23.1.8', + getDeploymentInfoCalls: 0, + mockDeployments: () => { + const blockchainInfo = readCoreAnswer('23.1.8', 'getblockchaininfo-1112'); + delete blockchainInfo.result.softforks.mn_rr; + mockRpcClient.getBlockchainInfo.returns(blockchainInfo); + }, + }, + { + coreVersion: '24.0.0-rc.3', + getDeploymentInfoCalls: 1, + mockDeployments: () => { + const deploymentInfo = readCoreAnswer('24.0.0-rc.3', 'getdeploymentinfo-1112'); + delete deploymentInfo.result.deployments.mn_rr; + mockRpcClient.getBlockchainInfo.returns(readCoreAnswer('24.0.0-rc.3', 'getblockchaininfo-1112')); + mockRpcClient.getDeploymentInfo.returns(deploymentInfo); + }, + }, + ].forEach(({ coreVersion, getDeploymentInfoCalls, mockDeployments }) => { + it(`should return the empty platform scope instead of failing when Core ${coreVersion} reports no mn_rr deployment`, async () => { + mockRpcClient.mnsync.withArgs('status').returns(readCoreAnswer(coreVersion, 'mnsync-status')); + mockDeployments(); + + const scope = await getPlatformScope(config); + + expect(scope.platformActivation).to.be.null(); + expect(scope.coreIsSynced).to.be.null(); + expect(scope.tenderdash.serviceStatus).to.be.null(); + expect(scope.drive).to.deep.equal({ + dockerStatus: null, + serviceStatus: null, + version: null, + }); + expect(mockRpcClient.getDeploymentInfo.callCount).to.equal(getDeploymentInfoCalls); + }); + }); + }); }); }); diff --git a/packages/dashpay-contract/package.json b/packages/dashpay-contract/package.json index 881366f57b1..602ca35890d 100644 --- a/packages/dashpay-contract/package.json +++ b/packages/dashpay-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dashpay-contract", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "Reference contract of the DashPay DPA on Dash Evolution", "scripts": { "lint": "eslint .", diff --git a/packages/document-history-contract/package.json b/packages/document-history-contract/package.json index 3dbe42e6e21..b0602b4bf99 100644 --- a/packages/document-history-contract/package.json +++ b/packages/document-history-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/document-history-contract", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "The document history contract", "scripts": { "lint": "eslint .", diff --git a/packages/dpns-contract/package.json b/packages/dpns-contract/package.json index 670542f76a2..b091cc7e683 100644 --- a/packages/dpns-contract/package.json +++ b/packages/dpns-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dpns-contract", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "A contract and helper scripts for DPNS DApp", "scripts": { "lint": "eslint .", diff --git a/packages/js-dapi-client/package.json b/packages/js-dapi-client/package.json index 9e20adabad4..79ac333e0e0 100644 --- a/packages/js-dapi-client/package.json +++ b/packages/js-dapi-client/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dapi-client", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "Client library used to access Dash DAPI endpoints", "main": "lib/index.js", "contributors": [ diff --git a/packages/js-dash-sdk/package.json b/packages/js-dash-sdk/package.json index dffed2f066a..dfb22169f79 100644 --- a/packages/js-dash-sdk/package.json +++ b/packages/js-dash-sdk/package.json @@ -1,6 +1,6 @@ { "name": "dash", - "version": "8.0.0-rc.3", + "version": "8.0.0-rc.4", "description": "Dash library for JavaScript/TypeScript ecosystem (Wallet, DAPI, Primitives, BLS, ...)", "main": "build/index.js", "unpkg": "dist/dash.min.js", diff --git a/packages/js-evo-sdk/package.json b/packages/js-evo-sdk/package.json index 213f95cea97..ae5a6d8a97c 100644 --- a/packages/js-evo-sdk/package.json +++ b/packages/js-evo-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/evo-sdk", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "type": "module", "main": "./dist/evo-sdk.module.js", "types": "./dist/sdk.d.ts", diff --git a/packages/js-grpc-common/package.json b/packages/js-grpc-common/package.json index 4d629bb66d5..7c02bdad5e4 100644 --- a/packages/js-grpc-common/package.json +++ b/packages/js-grpc-common/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/grpc-common", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "Common GRPC library", "main": "index.js", "scripts": { diff --git a/packages/keyword-search-contract/package.json b/packages/keyword-search-contract/package.json index 45bbf88d802..77f143f2232 100644 --- a/packages/keyword-search-contract/package.json +++ b/packages/keyword-search-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/keyword-search-contract", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "A contract that allows searching for contracts", "scripts": { "lint": "eslint .", diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/15.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/15.json new file mode 100644 index 00000000000..ed27781b3eb --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/15.json @@ -0,0 +1,4202 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "4cdbf8acfcfa07b5745b6aacd7f9d5bb", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `lastAppliedChainLockHeight` INTEGER, `dashPayBackfillFloor` INTEGER, `dashPayBackfillRewoundFrom` INTEGER, `dashPayBackfillCovered` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillFloor", + "columnName": "dashPayBackfillFloor", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillRewoundFrom", + "columnName": "dashPayBackfillRewoundFrom", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillCovered", + "columnName": "dashPayBackfillCovered", + "affinity": "BLOB" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, `supersededByTxid` BLOB, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + }, + { + "fieldPath": "supersededByTxid", + "columnName": "supersededByTxid", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `totalBudget` INTEGER, `expiresAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `contractBoundsKind` INTEGER, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "totalBudget", + "columnName": "totalBudget", + "affinity": "INTEGER" + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "contractBoundsKind", + "columnName": "contractBoundsKind", + "affinity": "INTEGER" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `externalAccountReference` INTEGER, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "externalAccountReference", + "columnName": "externalAccountReference", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSweptTombstone", + "columnName": "isSweptTombstone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + }, + { + "name": "index_pending_inputs_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", + "unique": false, + "columnNames": [ + "walletId", + "isSweptTombstone", + "winnerMinedHeight" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `oncePerIdentityDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "oncePerIdentityDistribution", + "columnName": "oncePerIdentityDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4cdbf8acfcfa07b5745b6aacd7f9d5bb')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index 49398a2d13a..9a98201510b 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -613,13 +613,90 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v14 -> v15 adds the DashPay backfill record columns on `wallets` + * (`dashPayBackfillFloor`, `dashPayBackfillRewoundFrom`, + * `dashPayBackfillCovered`), all nullable and additive. A pre-migration + * wallet reads back with no record — native then rewinds once, as it did + * before the record existed — and a row written afterwards keeps all + * three, so a restored wallet resumes its backfill instead of restarting + * it (dashpay/platform#4302). + */ + @Test + fun migrate14To15AddsDashPayBackfillColumns() { + helper.createDatabase(dbName, 14).apply { + execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 2309809, 0, 0, 0, 0)", + ) + execSQL( + "INSERT INTO identities (identityId, balance, revision, isLocal, identityType, " + + "createdAt, lastUpdated, networkRaw, identityIndex, walletId) " + + "VALUES (x'0A', 0, 0, 1, 'User', 0, 0, 1, 0, x'01')", + ) + execSQL( + "INSERT INTO dashpay_contact_requests (networkRaw, ownerIdentityId, " + + "contactIdentityId, isOutgoing, senderKeyIndex, recipientKeyIndex, " + + "accountReference, encryptedPublicKey, coreHeightCreatedAt, createdAtMillis, " + + "paymentChannelBroken, contactHidden, createdAt, lastUpdated) " + + "VALUES (1, x'0A', x'0B', 0, 2, 3, 4, x'05', 1226329, 0, 0, 0, 0, 0)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(dbName, 15, true, DashDatabase.MIGRATION_14_15) + + db.query( + "SELECT externalAccountReference FROM dashpay_contact_requests WHERE ownerIdentityId = x'0A'", + ).use { c -> + assertTrue(c.moveToFirst()) + assertTrue("pre-migration contact rows carry no outbound-account marker", c.isNull(0)) + } + db.execSQL( + "UPDATE dashpay_contact_requests SET externalAccountReference = 4 WHERE ownerIdentityId = x'0A'", + ) + db.query( + "SELECT externalAccountReference FROM dashpay_contact_requests WHERE ownerIdentityId = x'0A'", + ).use { c -> + assertTrue(c.moveToFirst()) + assertEquals(4, c.getInt(0)) + } + + db.query( + "SELECT dashPayBackfillFloor, dashPayBackfillRewoundFrom, dashPayBackfillCovered, " + + "syncedHeight FROM wallets WHERE walletId = x'01'", + ).use { c -> + assertTrue(c.moveToFirst()) + assertTrue("pre-migration wallets have no backfill record", c.isNull(0)) + assertTrue(c.isNull(1)) + assertTrue(c.isNull(2)) + assertEquals("the cursor itself is untouched", 2_309_809, c.getInt(3)) + } + db.execSQL( + "UPDATE wallets SET dashPayBackfillFloor = 2167092, " + + "dashPayBackfillRewoundFrom = 2537092, dashPayBackfillCovered = x'AABB' " + + "WHERE walletId = x'01'", + ) + db.query( + "SELECT dashPayBackfillFloor, dashPayBackfillRewoundFrom, dashPayBackfillCovered " + + "FROM wallets WHERE walletId = x'01'", + ).use { c -> + assertTrue(c.moveToFirst()) + assertEquals(2_167_092, c.getInt(0)) + assertEquals(2_537_092, c.getInt(1)) + assertTrue(byteArrayOf(0xAA.toByte(), 0xBB.toByte()).contentEquals(c.getBlob(2))) + } + db.close() + } + /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 14, + 15, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -631,16 +708,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_11_12, DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, + DashDatabase.MIGRATION_14_15, ).close() } - /** The full chain from v1 must also land on a valid v14 schema. */ + /** The full chain from v1 must also land on a valid v15 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 14, + 15, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -655,6 +733,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_11_12, DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, + DashDatabase.MIGRATION_14_15, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 76759d63ed3..f662cfae2fe 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -437,6 +437,40 @@ abstract class NativePersistenceBridge { */ open fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = 0 + /** + * The wallet's DashPay coreHeight backfill record — the durable half of + * the contact rescan guard (dashpay/platform#4302). Fired inside the + * round, after the wallet changeset header, on every round whose + * changeset carries a record; the rescan sweep writes it on the SAME + * round as the lowered `syncedHeight` it belongs with, so a handler that + * stores both holds a record that only ever vouches for a cursor it also + * stored. Descriptor `([BII[BI)I`. + * + * Whole-record semantics: replace what the wallet row holds, never + * merge. [covered] is ONE flat `byte[]` of `72 * coveredCount` bytes — + * per receival account the owner identity id (32), the contact identity + * id (32), the account index (u32) and the height it is covered from + * (u32), both little-endian — the same + * packing the sweep slot uses for its txids. Store it as an opaque blob + * and hand it back unchanged on [WalletRestoreData.dashPayBackfillCovered]; + * only native reads it. + * + * Purely additive: an implementation that ignores it keeps today's + * behaviour — the backfill re-fires on every launch, re-walking every + * filter from the earliest contact's core height. Slow, never lossy. + * + * Delivered only to a bridge that also overrides [onWalletChangesetHeader]: + * the record vouches for the cursor that callback stores, so a bridge + * keeping the inherited no-op header never receives it. + */ + open fun onWalletChangesetDashPayBackfill( + walletId: ByteArray, + floor: Int, + rewoundFrom: Int, + covered: ByteArray, + coveredCount: Int, + ): Int = 0 + // ── Identities ──────────────────────────────────────────────────── /** @@ -562,14 +596,25 @@ abstract class NativePersistenceBridge { // ── Contacts ────────────────────────────────────────────────────── /** - * One `ContactRequestFFI` upsert. Descriptor - * `([B[B[BZIII[B[B[BIJZLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;[I)I`. + * One `ContactRequestFFI` upsert, marker-less form (the signature this + * API had before the outbound-account marker). Native calls the + * overload below; this one stays so existing implementations keep + * compiling and keep receiving rows through the delegating default. * * The tail block ([paymentChannelBroken] / [alias] / [note] / * [isHidden] / [contactAccountLabel] / [acceptedAccounts]) is * established-row relationship metadata (contactInfo + DIP-15 * accepted accounts) — null / false / empty on pending rows. * [acceptedAccounts] is never null (empty when absent). + * + * [hasExternalAccountReference] / [externalAccountReference] mirror + * `EstablishedContact::external_account_reference`: the incoming + * `accountReference` the registered outbound (sending) account was + * built from. Store it and hand it back on + * [ContactRequestRestoreData]; a cold start that restores `None` + * treats the account as rotated and rebuilds it on every launch + * (dashpay/platform#4302). Replicated onto both established rows; + * `false` / 0 on pending rows. */ @Suppress("LongParameterList") open fun onPersistContactUpsert( @@ -593,6 +638,45 @@ abstract class NativePersistenceBridge { acceptedAccounts: IntArray, ): Int = 0 + /** + * The overload native calls (descriptor + * `([B[B[BZIII[B[B[BIJZLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;[IZI)I`): + * the row above plus `EstablishedContact::external_account_reference` as + * `(hasExternalAccountReference, externalAccountReference)`. The default + * body delegates to the marker-less overload, so an implementation that + * overrides only that one keeps persisting every other field and merely + * loses the marker (native then rebuilds the outbound account once per + * launch, the pre-marker behaviour). Override this one to store it. + */ + @Suppress("LongParameterList") + open fun onPersistContactUpsert( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isOutgoing: Boolean, + senderKeyIndex: Int, + recipientKeyIndex: Int, + accountReference: Int, + encryptedPublicKey: ByteArray, + encryptedAccountLabel: ByteArray?, + autoAcceptProof: ByteArray?, + coreHeightCreatedAt: Int, + createdAt: Long, + paymentChannelBroken: Boolean, + alias: String?, + note: String?, + isHidden: Boolean, + contactAccountLabel: String?, + acceptedAccounts: IntArray, + hasExternalAccountReference: Boolean, + externalAccountReference: Int, + ): Int = onPersistContactUpsert( + walletId, ownerId, contactId, isOutgoing, senderKeyIndex, recipientKeyIndex, + accountReference, encryptedPublicKey, encryptedAccountLabel, autoAcceptProof, + coreHeightCreatedAt, createdAt, paymentChannelBroken, alias, note, isHidden, + contactAccountLabel, acceptedAccounts, + ) + /** One sent-side `ContactRequestRemovalFFI`. Descriptor `([B[B[B)I`. */ open fun onPersistContactRemovalSent( walletId: ByteArray, @@ -968,7 +1052,41 @@ class WalletRestoreData( * `loadWalletList`. */ @JvmField val lastAppliedChainLockBytes: ByteArray, -) +) { + // The DashPay backfill record rides as body properties, not constructor + // parameters, so this class keeps exactly the JVM constructors it had + // before the record existed: a bridge compiled against the older SDK — + // Java, or Kotlin using the defaulted `providerSpecialTxs` — still links + // (dashpay/platform#4302 review). Set them with `apply { … }`; native + // reads them by field name either way. + + /** + * Whether the wallet row holds a DashPay coreHeight backfill record + * (dashpay/platform#4302). `false` for a wallet that never rewound for a + * contact or a row persisted before the record existed; native then + * ignores the three fields below, and the first rescan sweep behaves as + * it always did and writes one. Mirror of + * `WalletRestoreEntryFFI.has_dashpay_backfill`. + */ + @JvmField var hasDashPayBackfill: Boolean = false + + /** Lowest height the backfill rewound the cursor to. */ + @JvmField var dashPayBackfillFloor: Int = 0 + + /** + * Highest cursor the backfill rewound from — the height the scan climbs + * back to for the backfill to be complete. + */ + @JvmField var dashPayBackfillRewoundFrom: Int = 0 + + /** + * The record's cover set, exactly as [NativePersistenceBridge.onWalletChangesetDashPayBackfill] + * delivered it: `72` bytes per covered receival account. Native re-packs it into + * a `DashPayBackfillCoveredContactFFI` array; a blob that is not a whole + * number of entries is read as no record. + */ + @JvmField var dashPayBackfillCovered: ByteArray = ByteArray(0) +} /** * Kotlin staging row for the unchanged `ProviderSpecialTxRestoreEntryFFI`. @@ -1258,7 +1376,21 @@ class ContactRequestRestoreData( @JvmField val isHidden: Boolean, @JvmField val contactAccountLabel: String?, @JvmField val acceptedAccounts: IntArray, -) +) { + // Body properties, not constructor parameters, for the same reason as + // [WalletRestoreData]'s backfill fields: the class keeps the JVM + // constructor it had before the marker existed. + + /** + * `EstablishedContact::external_account_reference` as a `(present, + * value)` pair, exactly as [NativePersistenceBridge.onPersistContactUpsert] + * delivered it. `false` restores `None`, which makes native rebuild the + * outbound account once on the next sweep. + */ + @JvmField var hasExternalAccountReference: Boolean = false + + @JvmField var externalAccountReference: Int = 0 +} /** * One flat identity-public-key row — mirror of `IdentityKeyRestoreFFI`. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index e74e3b00954..0283ac2904f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -161,9 +161,27 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * contract's `oncePerIdentityDistribution` block as JSON, so the claim * screen can offer the third distribution kind. NULL for every pre-existing * row; the next contract materialization fills it in. + * + * Version 15 (durable DashPay backfill, dashpay/platform#4302): adds the + * nullable `wallets.dashPayBackfillFloor`, `wallets.dashPayBackfillRewoundFrom` + * and `wallets.dashPayBackfillCovered` columns — the record native writes + * through `onWalletChangesetDashPayBackfill` on the same round as the lowered + * `syncedHeight` it belongs with, and reads back on `loadWalletList`. The + * contact rescan used to guard against re-lowering the cursor in memory only, + * while the cursor it lowers is durable, so every fresh process rewound again + * and re-walked every filter from the earliest contact's core height. All + * three NULL (every pre-migration row) means no record: native rewinds once + * more, writes one, and never again for the contacts it covers. The same + * version adds the nullable `dashpay_contact_requests.externalAccountReference` + * column — `EstablishedContact::external_account_reference`, the marker that + * says which incoming `accountReference` the outbound sending account was + * built from. It was never persisted, so every cold start read it as absent, + * treated the account as rotated, tore it down and rebuilt it — and that + * rebuild was the per-launch cursor rewind. NULL for pre-migration rows: + * native rebuilds once, stamps it, and never again. */ @Database( - version = 14, + version = 15, exportSchema = true, entities = [ WalletEntity::class, @@ -679,6 +697,25 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v14 -> v15: additive nullable `wallets.dashPayBackfillFloor`, + * `wallets.dashPayBackfillRewoundFrom`, `wallets.dashPayBackfillCovered` + * and `dashpay_contact_requests.externalAccountReference`, see the + * version-15 class doc above. NULL for every pre-existing row — no + * record on file and no marker, so the next sweep rebuilds the + * outbound account once, rescans once, and writes both. + */ + val MIGRATION_14_15: Migration = object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `wallets` ADD COLUMN `dashPayBackfillFloor` INTEGER") + db.execSQL("ALTER TABLE `wallets` ADD COLUMN `dashPayBackfillRewoundFrom` INTEGER") + db.execSQL("ALTER TABLE `wallets` ADD COLUMN `dashPayBackfillCovered` BLOB") + db.execSQL( + "ALTER TABLE `dashpay_contact_requests` ADD COLUMN `externalAccountReference` INTEGER", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -708,6 +745,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, + MIGRATION_14_15, ) .build() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 2f4ad4d12d5..dbaa458df17 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -775,6 +775,39 @@ class PlatformWalletPersistenceHandler( * height, no collection), so a chainlock-advancing round collects too, * not only a header round. */ + /** + * The wallet's DashPay coreHeight backfill record (dashpay/platform#4302): + * whole-record replace of the three `wallets.dashPayBackfill*` columns, + * staged into the round so it commits with — or rolls back with — the + * lowered `syncedHeight` the header slot wrote moments earlier in the + * same round. A record that vouches for a cursor the round never + * committed must not survive on its own. + */ + override fun onWalletChangesetDashPayBackfill( + walletId: ByteArray, + floor: Int, + rewoundFrom: Int, + covered: ByteArray, + coveredCount: Int, + ): Int = guarded { + require(covered.size == coveredCount * DASHPAY_BACKFILL_COVERED_ENTRY_SIZE) { + "DashPay backfill cover set must be $DASHPAY_BACKFILL_COVERED_ENTRY_SIZE bytes per contact" + } + stage(walletId) { db -> + // Drop stale post-deletion callbacks (can't resurrect a wallet). + val wallet = db.walletDao().getByWalletId(walletId) ?: return@stage + db.walletDao().upsert( + wallet.copy( + dashPayBackfillFloor = floor, + dashPayBackfillRewoundFrom = rewoundFrom, + dashPayBackfillCovered = covered.copyOf(), + lastUpdated = now(), + ), + ) + } + 0 + } + override fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = guarded { val round = openRound(walletId) round?.finalityAdvanced = true @@ -2095,6 +2128,7 @@ class PlatformWalletPersistenceHandler( // ── Contacts ────────────────────────────────────────────────────── + /** Marker-less form: same row, no outbound-account marker (stored as NULL). */ override fun onPersistContactUpsert( walletId: ByteArray, ownerId: ByteArray, @@ -2114,6 +2148,36 @@ class PlatformWalletPersistenceHandler( isHidden: Boolean, contactAccountLabel: String?, acceptedAccounts: IntArray, + ): Int = onPersistContactUpsert( + walletId, ownerId, contactId, isOutgoing, senderKeyIndex, recipientKeyIndex, + accountReference, encryptedPublicKey, encryptedAccountLabel, autoAcceptProof, + coreHeightCreatedAt, createdAt, paymentChannelBroken, alias, note, isHidden, + contactAccountLabel, acceptedAccounts, + hasExternalAccountReference = false, + externalAccountReference = 0, + ) + + override fun onPersistContactUpsert( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isOutgoing: Boolean, + senderKeyIndex: Int, + recipientKeyIndex: Int, + accountReference: Int, + encryptedPublicKey: ByteArray, + encryptedAccountLabel: ByteArray?, + autoAcceptProof: ByteArray?, + coreHeightCreatedAt: Int, + createdAt: Long, + paymentChannelBroken: Boolean, + alias: String?, + note: String?, + isHidden: Boolean, + contactAccountLabel: String?, + acceptedAccounts: IntArray, + hasExternalAccountReference: Boolean, + externalAccountReference: Int, ): Int = guarded { stage(walletId) { db -> // Owner identity must exist; skip silently otherwise (replayed @@ -2139,6 +2203,8 @@ class PlatformWalletPersistenceHandler( contactHidden = isHidden, contactAccountLabel = contactAccountLabel, contactAcceptedAccounts = encodeAcceptedAccounts(acceptedAccounts), + externalAccountReference = + if (hasExternalAccountReference) externalAccountReference else null, lastUpdated = now(), ), ) @@ -2673,6 +2739,15 @@ class PlatformWalletPersistenceHandler( // slice. val lastAppliedChainLockBytes = w.lastAppliedChainLockBytes ?: ByteArray(0) + // Persisted DashPay backfill record — the durable half of the + // contact rescan guard. Present only when every column is set + // (one round writes all three); otherwise native treats the + // wallet as never backfilled and rewinds once, as before. + val backfillFloor = w.dashPayBackfillFloor + val backfillRewoundFrom = w.dashPayBackfillRewoundFrom + val backfillCovered = w.dashPayBackfillCovered + val hasBackfill = + backfillFloor != null && backfillRewoundFrom != null && backfillCovered != null out.add( WalletRestoreData( walletId = w.walletId, @@ -2694,7 +2769,14 @@ class PlatformWalletPersistenceHandler( unresolvedAssetLockTxRecords = unresolvedAssetLockTxRecords, providerSpecialTxs = providerSpecialTxs, lastAppliedChainLockBytes = lastAppliedChainLockBytes, - ), + ).apply { + if (hasBackfill) { + hasDashPayBackfill = true + dashPayBackfillFloor = backfillFloor!! + dashPayBackfillRewoundFrom = backfillRewoundFrom!! + dashPayBackfillCovered = backfillCovered!! + } + }, ) } out.toTypedArray() @@ -2935,7 +3017,12 @@ class PlatformWalletPersistenceHandler( isHidden = c.contactHidden, contactAccountLabel = c.contactAccountLabel, acceptedAccounts = decodeAcceptedAccounts(c.contactAcceptedAccounts), - ) + ).apply { + c.externalAccountReference?.let { reference -> + hasExternalAccountReference = true + externalAccountReference = reference + } + } }.toTypedArray() // Ignored senders (per-sender mute) — restores the Rust // `ignored_senders` set so a previously-ignored sender doesn't @@ -4024,6 +4111,13 @@ class PlatformWalletPersistenceHandler( internal const val CAPABILITY_WALLET_RESTORE: Long = 0x80 internal const val CAPABILITY_DPNS_NAME_STATES: Long = 0x100 internal const val CAPABILITY_TRACKED_ASSET_LOCKS: Long = 0x200 + /** + * Bytes per receival account in the opaque cover set + * [onWalletChangesetDashPayBackfill] delivers: owner id (32), + * contact id (32), account index (4), covered-from height (4). + */ + internal const val DASHPAY_BACKFILL_COVERED_ENTRY_SIZE: Int = 32 + 32 + 4 + 4 + internal const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactRequestEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactRequestEntity.kt index aae74e5a845..61254e1c234 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactRequestEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/DashpayContactRequestEntity.kt @@ -81,6 +81,16 @@ data class DashpayContactRequestEntity( * `contactAcceptedAccounts` (`[UInt32]`). */ val contactAcceptedAccounts: ByteArray? = null, + /** + * `EstablishedContact::external_account_reference` — the incoming + * `accountReference` the registered outbound (sending) account was + * built from; null when no account is known to be built for the + * current reference. Relationship-level (both established rows), null + * on pending rows. Restoring it is what stops native from tearing the + * outbound account down and rebuilding it on every cold start + * (dashpay/platform#4302). + */ + val externalAccountReference: Int? = null, val createdAt: Date = Date(), val lastUpdated: Date = Date(), ) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt index 200edbe1947..06682b2c724 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt @@ -68,6 +68,21 @@ data class WalletEntity( * has been persisted". */ val lastAppliedChainLockHeight: Int? = null, + /** + * DashPay coreHeight backfill record (dashpay/platform#4302), delivered + * whole by `onWalletChangesetDashPayBackfill` on the same round as the + * lowered `syncedHeight` it belongs with, and handed back unchanged on + * `loadWalletList`. All three NULL (the pre-migration reading) means no + * record: native re-runs the backfill once and writes one. `floor` is the + * lowest height the backfill rewound to, `rewoundFrom` the cursor it + * rewound from — the scan is complete once `syncedHeight` climbs back + * past it — and `covered` the opaque cover set (72 bytes per receival + * account: owner id, contact id, account index, covered-from height) + * only native reads. + */ + val dashPayBackfillFloor: Int? = null, + val dashPayBackfillRewoundFrom: Int? = null, + val dashPayBackfillCovered: ByteArray? = null, val isImported: Boolean = false, val createdAt: Date = Date(), val lastUpdated: Date = Date(), diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt index fb2ba7896b7..41326e84f92 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -233,15 +233,16 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion14WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion15WithTheSweepHoldIndexes() = runTest { // The sweep-hold columns land in ONE migration (10 → 11), with the // two `pending_inputs` indexes the sweep's claimed-row lookup // (`spendingTxid`) and the end-of-round collector // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. // 11 → 12 adds the identity key usage limits columns on top, - // 12 → 13 the contract bounds kind, and 13 → 14 the token - // once-per-identity distribution block. - assertEquals(14, db.openHelper.readableDatabase.version) + // 12 → 13 the contract bounds kind, 13 → 14 the token + // once-per-identity distribution block, and 14 → 15 the DashPay + // backfill record columns on `wallets`. + assertEquals(15, db.openHelper.readableDatabase.version) val indexes = mutableSetOf() db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> val nameColumn = c.getColumnIndexOrThrow("name") @@ -293,6 +294,53 @@ class DashDatabaseTest { assertTrue(found) } + @Test + fun shouldHaveNullableDashPayBackfillColumnsOnWallets() = runTest { + // Version 15 (14 → 15): all three nullable with no default, so a + // legacy row reads back as "no record" and native rewinds once. + val expected = mapOf( + "dashPayBackfillFloor" to "INTEGER", + "dashPayBackfillRewoundFrom" to "INTEGER", + "dashPayBackfillCovered" to "BLOB", + ) + val found = mutableMapOf() + db.openHelper.readableDatabase.query("PRAGMA table_info('wallets')").use { c -> + val name = c.getColumnIndexOrThrow("name") + val type = c.getColumnIndexOrThrow("type") + val notNull = c.getColumnIndexOrThrow("notnull") + val default = c.getColumnIndexOrThrow("dflt_value") + while (c.moveToNext()) { + val column = c.getString(name) + if (column !in expected) continue + found[column] = c.getString(type) + assertEquals(column, 0, c.getInt(notNull)) + assertTrue(column, c.isNull(default)) + } + } + assertEquals(expected, found) + + db.walletDao().upsert(WalletEntity(walletId = walletId, networkRaw = 1)) + val legacy = db.walletDao().getByWalletId(walletId)!! + assertNull(legacy.dashPayBackfillFloor) + assertNull(legacy.dashPayBackfillRewoundFrom) + assertNull(legacy.dashPayBackfillCovered) + + // Same version: the nullable outbound-account marker on contact rows. + var markerFound = false + db.openHelper.readableDatabase.query("PRAGMA table_info('dashpay_contact_requests')").use { c -> + val name = c.getColumnIndexOrThrow("name") + val type = c.getColumnIndexOrThrow("type") + val notNull = c.getColumnIndexOrThrow("notnull") + while (c.moveToNext()) { + if (c.getString(name) != "externalAccountReference") continue + markerFound = true + assertEquals("INTEGER", c.getString(type)) + assertEquals(0, c.getInt(notNull)) + } + } + assertTrue(markerFound) + } + @Test fun advanceChainLockHeightIsANarrowMonotonicMaxWrite() = runTest { db.walletDao().upsert(WalletEntity(walletId = walletId, networkRaw = 1, name = "w", syncedHeight = 7)) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index b7b1bfd63e9..cc6dede2141 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -4833,6 +4833,115 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(mutedId.contentEquals(identity.ignoredSenders[0])) } + /** + * dashpay/platform#4302: the outbound-account marker + * (`EstablishedContact::external_account_reference`) must round-trip, + * or every cold start rebuilds the outbound account. `false` stores + * NULL and restores as absent; `true` stores the value and restores it. + */ + @Test + fun contactUpsertRoundTripsTheExternalAccountReference() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val xpub = ByteArray(78) { 30 } + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + val ownerId = ByteArray(32) { 23 } + val contactId = ByteArray(32) { 24 } + seedIdentity(ownerId) + + // Absent marker (the default trailing arguments). + persistIncomingContact(ownerId, contactId) + val before = handler.onLoadWalletList().single().identities.single().contacts.single() + assertFalse(before.hasExternalAccountReference) + assertEquals(0, before.externalAccountReference) + assertNull(db.dashpayDao().getContactRequestsByOwner(ownerId).single().externalAccountReference) + + // Stamped marker. + handler.onChangesetBegin(walletId) + handler.onPersistContactUpsert( + walletId = walletId, + ownerId = ownerId, + contactId = contactId, + isOutgoing = false, + senderKeyIndex = 2, + recipientKeyIndex = 3, + accountReference = 4, + encryptedPublicKey = ByteArray(96) { 5 }, + encryptedAccountLabel = null, + autoAcceptProof = null, + coreHeightCreatedAt = 100_000, + createdAt = 1_700_000_000_000, + paymentChannelBroken = false, + alias = null, + note = null, + isHidden = false, + contactAccountLabel = null, + acceptedAccounts = IntArray(0), + hasExternalAccountReference = true, + externalAccountReference = 4, + ) + handler.onChangesetEnd(walletId, success = true) + val after = handler.onLoadWalletList().single().identities.single().contacts.single() + assertTrue(after.hasExternalAccountReference) + assertEquals(4, after.externalAccountReference) + assertEquals(4, db.dashpayDao().getContactRequestsByOwner(ownerId).single().externalAccountReference) + } + + /** + * dashpay/platform#4302 review: adding the backfill record and the + * outbound-account marker must not change the JVM constructors of the two + * restore holders, or a bridge compiled against the previous SDK throws + * `NoSuchMethodError` when it builds restore rows. Pins both the + * full-arity constructor (Java callers) and `WalletRestoreData`'s + * default-argument synthetic (Kotlin callers leaving `providerSpecialTxs` + * out) against the v4.2-dev parameter lists. + */ + @Test + fun restoreHoldersKeepTheirPreviousJvmConstructors() { + fun ctorTypes(cls: Class<*>) = cls.declaredConstructors.map { it.parameterTypes.map { p -> p.name } }.toSet() + + val walletBase = listOf( + "[B", "int", "[Lorg.dashfoundation.dashsdk.ffi.AccountSpecData;", "long", "long", "long", + "int", "int", "int", "long", + "[Lorg.dashfoundation.dashsdk.ffi.IdentityRestoreData;", + "[Lorg.dashfoundation.dashsdk.ffi.PlatformAddressBalanceRestoreData;", + "[Lorg.dashfoundation.dashsdk.ffi.UtxoRestoreData;", + "[Lorg.dashfoundation.dashsdk.ffi.CoreAddressPoolRestoreData;", + "[Lorg.dashfoundation.dashsdk.ffi.TrackedAssetLockRestoreData;", + "[Lorg.dashfoundation.dashsdk.ffi.UnresolvedAssetLockTxRecordData;", + "[Lorg.dashfoundation.dashsdk.ffi.ProviderSpecialTxRestoreData;", + "[B", + ) + val walletCtors = ctorTypes(org.dashfoundation.dashsdk.ffi.WalletRestoreData::class.java) + assertTrue("full-arity constructor unchanged", walletCtors.contains(walletBase)) + assertTrue( + "default-argument constructor unchanged", + walletCtors.contains(walletBase + listOf("int", "kotlin.jvm.internal.DefaultConstructorMarker")), + ) + + val contactBase = listOf( + "[B", "[B", "boolean", "int", "int", "int", "[B", "[B", "[B", "int", "long", "boolean", + "java.lang.String", "java.lang.String", "boolean", "java.lang.String", "[I", + ) + assertTrue( + "contact restore constructor unchanged", + ctorTypes(org.dashfoundation.dashsdk.ffi.ContactRequestRestoreData::class.java).contains(contactBase), + ) + + // The new fields are still plain fields native can read by name. + for ((cls, names) in listOf( + org.dashfoundation.dashsdk.ffi.WalletRestoreData::class.java to listOf( + "hasDashPayBackfill", "dashPayBackfillFloor", "dashPayBackfillRewoundFrom", "dashPayBackfillCovered", + ), + org.dashfoundation.dashsdk.ffi.ContactRequestRestoreData::class.java to listOf( + "hasExternalAccountReference", "externalAccountReference", + ), + )) { + for (name in names) assertNotNull("$name is a JVM field", cls.getField(name)) + } + } + @Test fun loadWalletListScopesToTheHandlerNetwork() = runTest { // A network-scoped handler must never hand the Rust loader a @@ -6727,6 +6836,156 @@ class PlatformWalletPersistenceHandlerTest { chainLockHeightRound(handler, 600) assertEquals(600, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) } + /** One packed cover-set entry: owner id ‖ contact id ‖ covered-from (LE u32). */ + private fun backfillEntry(owner: Byte, contact: Byte, coveredFrom: Int): ByteArray = + ByteArray(32) { owner } + ByteArray(32) { contact } + ByteArray(4) + byteArrayOf( + (coveredFrom and 0xFF).toByte(), + ((coveredFrom shr 8) and 0xFF).toByte(), + ((coveredFrom shr 16) and 0xFF).toByte(), + ((coveredFrom shr 24) and 0xFF).toByte(), + ) + + /** + * dashpay/platform#4302: the DashPay backfill record is a whole-record + * replace of the three `wallets.dashPayBackfill*` columns, written on + * the same round as the lowered `syncedHeight` the rescan carries. A + * later record replaces the earlier one outright — a contact the new + * record no longer lists is gone, never merged back — and the cover set + * is stored byte-for-byte so native reads back exactly what it wrote. + */ + @Test + fun onWalletChangesetDashPayBackfillReplacesTheRecordOnTheWalletRow() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val row = db.walletDao().getByWalletId(walletId)!! + assertNull("no record until the slot fires", row.dashPayBackfillFloor) + assertNull(row.dashPayBackfillRewoundFrom) + assertNull(row.dashPayBackfillCovered) + + val first = backfillEntry(1, 2, 2_167_092) + backfillEntry(1, 3, 2_300_000) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetHeader( + walletId = walletId, + hasSyncedHeight = true, + syncedHeight = 2_167_092, + hasBalance = false, + confirmedDelta = 0, + unconfirmedDelta = 0, + immatureDelta = 0, + lockedDelta = 0, + lastAppliedChainLockBytes = ByteArray(0), + ) + assertEquals( + 0, + handler.onWalletChangesetDashPayBackfill(walletId, 2_167_092, 2_537_092, first, 2), + ) + handler.onChangesetEnd(walletId, success = true) + + val stored = db.walletDao().getByWalletId(walletId)!! + assertEquals("the lowered cursor lands with the record", 2_167_092, stored.syncedHeight) + assertEquals(2_167_092, stored.dashPayBackfillFloor) + assertEquals(2_537_092, stored.dashPayBackfillRewoundFrom) + assertTrue(first.contentEquals(stored.dashPayBackfillCovered)) + + // A later record replaces the whole cover set. + val second = backfillEntry(1, 2, 2_167_092) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetDashPayBackfill(walletId, 2_100_000, 2_600_000, second, 1) + handler.onChangesetEnd(walletId, success = true) + val replaced = db.walletDao().getByWalletId(walletId)!! + assertEquals(2_100_000, replaced.dashPayBackfillFloor) + assertEquals(2_600_000, replaced.dashPayBackfillRewoundFrom) + assertTrue(second.contentEquals(replaced.dashPayBackfillCovered)) + assertEquals("sibling columns are untouched", 2_167_092, replaced.syncedHeight) + } + + /** + * The record vouches for the cursor written in its round, so a round + * that rolls back must take the record with it: a persisted record over + * a cursor still at its high-water would tell native the backfill + * resumed when it never started — the lossy direction. A malformed cover + * set (not a whole number of 68-byte entries) is refused outright. + */ + @Test + fun onWalletChangesetDashPayBackfillRollsBackWithItsRoundAndRefusesATornCoverSet() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetHeader( + walletId = walletId, + hasSyncedHeight = true, + syncedHeight = 2_537_092, + hasBalance = false, + confirmedDelta = 0, + unconfirmedDelta = 0, + immatureDelta = 0, + lockedDelta = 0, + lastAppliedChainLockBytes = ByteArray(0), + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetHeader( + walletId = walletId, + hasSyncedHeight = true, + syncedHeight = 2_167_092, + hasBalance = false, + confirmedDelta = 0, + unconfirmedDelta = 0, + immatureDelta = 0, + lockedDelta = 0, + lastAppliedChainLockBytes = ByteArray(0), + ) + handler.onWalletChangesetDashPayBackfill( + walletId, 2_167_092, 2_537_092, backfillEntry(1, 2, 2_167_092), 1, + ) + handler.onChangesetEnd(walletId, success = false) + + val row = db.walletDao().getByWalletId(walletId)!! + assertEquals("the rolled-back cursor stays at its committed value", 2_537_092, row.syncedHeight) + assertNull("the rolled-back record never lands", row.dashPayBackfillFloor) + assertNull(row.dashPayBackfillRewoundFrom) + assertNull(row.dashPayBackfillCovered) + + handler.onChangesetBegin(walletId) + val rc = handler.onWalletChangesetDashPayBackfill( + walletId, 2_167_092, 2_537_092, backfillEntry(1, 2, 2_167_092).copyOf(67), 1, + ) + handler.onChangesetEnd(walletId, success = rc == 0) + assertTrue("a torn cover set is refused", rc != 0) + assertNull(db.walletDao().getByWalletId(walletId)!!.dashPayBackfillFloor) + } + + /** + * The restore side of dashpay/platform#4302: a stored record comes back + * on `loadWalletList` exactly as written, and a wallet with no record + * (every pre-migration row) reports none, so native rewinds once for it + * as it always did. + */ + @Test + fun loadWalletListRoundTripsTheDashPayBackfillRecord() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val xpub = ByteArray(78) { 30 } + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + + val before = handler.onLoadWalletList().single() + assertFalse("no record on a fresh wallet", before.hasDashPayBackfill) + assertEquals(0, before.dashPayBackfillFloor) + assertEquals(0, before.dashPayBackfillRewoundFrom) + assertEquals(0, before.dashPayBackfillCovered.size) + + val covered = backfillEntry(1, 2, 2_167_092) + backfillEntry(1, 3, 2_300_000) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetDashPayBackfill(walletId, 2_167_092, 2_537_092, covered, 2) + handler.onChangesetEnd(walletId, success = true) + + val after = handler.onLoadWalletList().single() + assertTrue(after.hasDashPayBackfill) + assertEquals(2_167_092, after.dashPayBackfillFloor) + assertEquals(2_537_092, after.dashPayBackfillRewoundFrom) + assertTrue(covered.contentEquals(after.dashPayBackfillCovered)) + } + } /** diff --git a/packages/masternode-reward-shares-contract/package.json b/packages/masternode-reward-shares-contract/package.json index a1e2a88b545..01d9d85f498 100644 --- a/packages/masternode-reward-shares-contract/package.json +++ b/packages/masternode-reward-shares-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/masternode-reward-shares-contract", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "A contract and helper scripts for reward sharing", "scripts": { "lint": "eslint .", diff --git a/packages/moderation-charters-contract/package.json b/packages/moderation-charters-contract/package.json index 997e9312ff0..53a25695f14 100644 --- a/packages/moderation-charters-contract/package.json +++ b/packages/moderation-charters-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/moderation-charters-contract", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "A system contract for the charters of elected moderation teams", "scripts": { "lint": "eslint .", diff --git a/packages/platform-test-suite/package.json b/packages/platform-test-suite/package.json index 835ab4f1856..cbea6c73ab2 100644 --- a/packages/platform-test-suite/package.json +++ b/packages/platform-test-suite/package.json @@ -1,7 +1,7 @@ { "name": "@dashevo/platform-test-suite", "private": true, - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "Dash Network end-to-end tests", "engines": { "node": ">=22" diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs index 77d2899a2c0..58576b6567f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs @@ -1,24 +1,34 @@ use crate::consensus::basic::identity::{ IdentityPublicKeyLimitsNotAllowedError, InvalidIdentityPublicKeyBudgetError, + InvalidIdentityPublicKeyDataError, }; use crate::consensus::ConsensusError; -use crate::identity::{Purpose, SecurityLevel}; +use crate::identity::{KeyType, Purpose, SecurityLevel}; use crate::state_transition::public_key_in_creation::accessors::{ IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV1Getters, }; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use crate::validation::SimpleConsensusValidationResult; -use crate::ProtocolError; +use crate::{ProtocolError, PublicKeyValidationError}; use platform_version::version::PlatformVersion; +/// The size of a hash key's data, which is the 160-bit hash itself. +const HASH_KEY_DATA_SIZE: usize = 20; + impl IdentityPublicKeyInCreation { - /// v1 runs the v0 checks, then decides which keys may carry a budget or an expiry. + /// v1 runs the v0 checks, then decides which keys may carry a budget or an expiry, then + /// checks the size of each hash key's data. /// /// Limits exist to hand an application a key that can only do so much, so they are only /// allowed on AUTHENTICATION keys below the MASTER security level: the master key is what /// replaces a spent or expired key, and must not run out itself. A budget of zero could /// never sign anything, so it is refused instead of registering a dead key. Whether the /// expiry is still ahead needs the block time and is checked in state validation. + /// + /// The data of an ECDSA_HASH160, BIP13_SCRIPT_HASH or EDDSA_25519_HASH160 key is the hash + /// itself, so it must be 20 bytes; data of any other size has no hash to register. The data + /// of an ECDSA_SECP256K1 or BLS12_381 key is left to its proof of possession, which refuses + /// data that is not a public key (an uncompressed 65-byte ECDSA_SECP256K1 key is valid). #[inline(always)] pub(super) fn validate_identity_public_keys_structure_v1( identity_public_keys_with_witness: &[IdentityPublicKeyInCreation], @@ -56,6 +66,38 @@ impl IdentityPublicKeyInCreation { } }) .collect(); + if !validation_errors.is_empty() { + return Ok(SimpleConsensusValidationResult::new_with_errors( + validation_errors, + )); + } + + // Last of the rules, so a key set that also breaks another one is still refused for that + // rule. Without this rule a hash key of the wrong size fails when its hash is taken, as + // an internal error that keeps its transition out of every block: a transition this rule + // refuses never succeeded, and one refused for an earlier rule keeps its error. + let validation_errors: Vec = identity_public_keys_with_witness + .iter() + .filter(|identity_public_key| match identity_public_key.key_type() { + KeyType::ECDSA_HASH160 + | KeyType::BIP13_SCRIPT_HASH + | KeyType::EDDSA_25519_HASH160 => { + identity_public_key.data().len() != HASH_KEY_DATA_SIZE + } + KeyType::ECDSA_SECP256K1 | KeyType::BLS12_381 => false, + }) + .map(|identity_public_key| { + InvalidIdentityPublicKeyDataError::new( + identity_public_key.id(), + PublicKeyValidationError::new(format!( + "{} key data must be {HASH_KEY_DATA_SIZE} bytes, got {}", + identity_public_key.key_type(), + identity_public_key.data().len() + )), + ) + .into() + }) + .collect(); Ok(SimpleConsensusValidationResult::new_with_errors( validation_errors, @@ -67,7 +109,6 @@ impl IdentityPublicKeyInCreation { mod tests { use super::*; use crate::consensus::basic::BasicError; - use crate::identity::KeyType; use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; use crate::state_transition::public_key_in_creation::v1::IdentityPublicKeyInCreationV1; use platform_value::BinaryData; @@ -188,6 +229,134 @@ mod tests { )); } + fn key_with_data(id: u32, key_type: KeyType, data_len: usize) -> IdentityPublicKeyInCreation { + IdentityPublicKeyInCreationV0 { + id, + key_type, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + read_only: false, + data: BinaryData::new(vec![id as u8 + 2; data_len]), + signature: BinaryData::default(), + } + .into() + } + + const HASH_KEY_TYPES: [KeyType; 3] = [ + KeyType::ECDSA_HASH160, + KeyType::BIP13_SCRIPT_HASH, + KeyType::EDDSA_25519_HASH160, + ]; + + /// The data of a hash key is the hash itself. Data of any other size has no hash to take, + /// which would otherwise only surface when the key is registered, as an internal error. + #[test] + fn should_reject_hash_key_data_that_is_not_20_bytes() { + for key_type in HASH_KEY_TYPES { + for data_len in [0, 1, 19, 21, 33, 65] { + let result = validate(&[key_with_data(1, key_type, data_len)]); + assert!( + matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidIdentityPublicKeyDataError(e) + )] if e.public_key_id() == 1 + ), + "{key_type} with {data_len} bytes: {:?}", + result.errors + ); + } + let result = validate(&[key_with_data(1, key_type, 20)]); + assert!(result.is_valid(), "{key_type}: {:?}", result.errors); + } + } + + /// The proof of possession of an ECDSA_SECP256K1 or BLS12_381 key already refuses data that + /// is not a public key, and an uncompressed 65-byte ECDSA_SECP256K1 key is valid, so the + /// structure check leaves their data alone. + #[test] + fn should_leave_the_data_of_a_key_with_a_proof_of_possession_to_that_proof() { + for (key_type, data_len) in [ + (KeyType::ECDSA_SECP256K1, 0), + (KeyType::ECDSA_SECP256K1, 20), + (KeyType::ECDSA_SECP256K1, 33), + (KeyType::ECDSA_SECP256K1, 65), + (KeyType::BLS12_381, 0), + (KeyType::BLS12_381, 47), + (KeyType::BLS12_381, 48), + ] { + let result = validate(&[key_with_data(1, key_type, data_len)]); + assert!( + result.is_valid(), + "{key_type} with {data_len} bytes: {:?}", + result.errors + ); + } + } + + #[test] + fn should_report_every_hash_key_with_data_of_the_wrong_size() { + let result = validate(&[ + key_with_data(0, KeyType::ECDSA_HASH160, 19), + key_with_data(1, KeyType::ECDSA_HASH160, 20), + key_with_data(2, KeyType::BIP13_SCRIPT_HASH, 0), + ]); + let reported_key_ids: Vec<_> = result + .errors + .iter() + .map(|error| match error { + ConsensusError::BasicError(BasicError::InvalidIdentityPublicKeyDataError(e)) => { + e.public_key_id() + } + other => panic!("unexpected error {other:?}"), + }) + .collect(); + assert_eq!(reported_key_ids, vec![0, 2]); + } + + /// A key set that breaks another structure rule is refused for that rule even when a hash + /// key's data has the wrong size too: the key data rule runs last. + #[test] + fn should_report_a_failed_limit_rule_rather_than_the_key_data() { + let result = validate(&[ + key( + 1, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + Some(0), + None, + ), + key_with_data(2, KeyType::ECDSA_HASH160, 19), + ]); + assert!( + matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidIdentityPublicKeyBudgetError(e) + )] if e.public_key_id() == 1 + ), + "{:?}", + result.errors + ); + } + + /// Protocol version 13 selects v0, which does not check the size of a hash key's data: its + /// history replays unchanged. + #[test] + fn should_not_check_the_size_of_hash_key_data_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + for key_type in HASH_KEY_TYPES { + let result = IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &[key_with_data(1, key_type, 19)], + false, + platform_version, + ) + .expect("expected validation to run"); + assert!(result.is_valid(), "{key_type}: {:?}", result.errors); + } + } + #[test] fn should_report_every_offending_key() { let unlimited: IdentityPublicKeyInCreation = IdentityPublicKeyInCreationV0 { diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v1/mod.rs index 58380396215..3b02bd1ec5c 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_masternode_identities/v1/mod.rs @@ -19,7 +19,7 @@ use drive::drive::identity::key::fetch::{ use drive::util::batch::DriveOperation; use drive::util::batch::DriveOperation::IdentityOperation; use drive::util::batch::IdentityOperationType::{ - AddNewIdentity, AddNewKeysToIdentity, DisableIdentityKeys, ReEnableIdentityKeys, + AddNewIdentity, AddNewKeysToMasternodeIdentity, DisableIdentityKeys, ReEnableIdentityKeys, }; use crate::platform_types::platform_state::PlatformStateV0Methods; @@ -287,9 +287,8 @@ where } } if let Some(key) = new_key { - drive_operations.push(IdentityOperation(AddNewKeysToIdentity { + drive_operations.push(IdentityOperation(AddNewKeysToMasternodeIdentity { identity_id: owner_identifier, - unique_keys_to_add: vec![], non_unique_keys_to_add: vec![key], })); } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs index 5089b6a5219..9ac778d96d6 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_operator_identity/v0/mod.rs @@ -24,7 +24,7 @@ use drive::grovedb::Transaction; use drive::util::batch::DriveOperation; use drive::util::batch::DriveOperation::IdentityOperation; use drive::util::batch::IdentityOperationType::{ - AddNewIdentity, AddNewKeysToIdentity, DisableIdentityKeys, ReEnableIdentityKeys, + AddNewIdentity, AddNewKeysToMasternodeIdentity, DisableIdentityKeys, ReEnableIdentityKeys, }; impl Platform @@ -212,9 +212,8 @@ where } if !non_unique_keys_to_add.is_empty() { - drive_operations.push(IdentityOperation(AddNewKeysToIdentity { + drive_operations.push(IdentityOperation(AddNewKeysToMasternodeIdentity { identity_id: operator_identifier.to_buffer(), - unique_keys_to_add: vec![], non_unique_keys_to_add, })); } @@ -374,9 +373,8 @@ where if !non_unique_keys_to_add.is_empty() { // We should add keys that didn't exist before - drive_operations.push(IdentityOperation(AddNewKeysToIdentity { + drive_operations.push(IdentityOperation(AddNewKeysToMasternodeIdentity { identity_id: operator_identifier.to_buffer(), - unique_keys_to_add: vec![], non_unique_keys_to_add, })); } @@ -1174,7 +1172,7 @@ mod tests { let added_platform_node_key = drive_operations.iter().any(|operation| { if let drive::util::batch::DriveOperation::IdentityOperation( - drive::util::batch::IdentityOperationType::AddNewKeysToIdentity { + drive::util::batch::IdentityOperationType::AddNewKeysToMasternodeIdentity { identity_id, non_unique_keys_to_add, .. diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_owner_withdrawal_address/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_owner_withdrawal_address/v0/mod.rs index ed9a86e63f0..b43f665ab33 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_owner_withdrawal_address/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_owner_withdrawal_address/v0/mod.rs @@ -16,7 +16,7 @@ use drive::grovedb::Transaction; use drive::util::batch::DriveOperation; use drive::util::batch::DriveOperation::IdentityOperation; use drive::util::batch::IdentityOperationType::{ - AddNewKeysToIdentity, DisableIdentityKeys, ReEnableIdentityKeys, + AddNewKeysToMasternodeIdentity, DisableIdentityKeys, ReEnableIdentityKeys, }; impl Platform where @@ -114,9 +114,8 @@ where "add new withdrawal key to owner identity" ); - drive_operations.push(IdentityOperation(AddNewKeysToIdentity { + drive_operations.push(IdentityOperation(AddNewKeysToMasternodeIdentity { identity_id: owner_identifier, - unique_keys_to_add: vec![], non_unique_keys_to_add: vec![new_owner_withdrawal_key], })); } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_owner_withdrawal_address/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_owner_withdrawal_address/v1/mod.rs index 5a82bca92a0..4c14e1eac48 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_owner_withdrawal_address/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_identities/update_owner_withdrawal_address/v1/mod.rs @@ -16,7 +16,7 @@ use drive::grovedb::Transaction; use drive::util::batch::DriveOperation; use drive::util::batch::DriveOperation::IdentityOperation; use drive::util::batch::IdentityOperationType::{ - AddNewKeysToIdentity, DisableIdentityKeys, ReEnableIdentityKeys, + AddNewKeysToMasternodeIdentity, DisableIdentityKeys, ReEnableIdentityKeys, }; impl Platform where @@ -124,9 +124,8 @@ where "add new withdrawal key to owner identity" ); - drive_operations.push(IdentityOperation(AddNewKeysToIdentity { + drive_operations.push(IdentityOperation(AddNewKeysToMasternodeIdentity { identity_id: owner_identifier, - unique_keys_to_add: vec![], non_unique_keys_to_add: vec![new_owner_withdrawal_key], })); } diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/mod.rs index 4292296c059..56db59ce820 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/mod.rs @@ -1118,5 +1118,7 @@ mod test { } } +#[cfg(test)] +mod operator_key_encoding_tests; #[cfg(test)] mod versioning_tests; diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/operator_key_encoding_tests.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/operator_key_encoding_tests.rs new file mode 100644 index 00000000000..1665ce9b79c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/operator_key_encoding_tests.rs @@ -0,0 +1,728 @@ +//! Operator keys that Dash Core re-encodes, run through the masternode list update as Drive runs +//! it once per block. +//! +//! The fixtures under `core_v24_protx_list_diffs/legacy_operator_key` are `protx listdiff` output +//! recorded from Dash Core v24.0.0-rc.3 on regtest. Four regular masternodes are registered with +//! legacy-scheme operator keys, and DEPLOYMENT_V24 activates at Core height 400. Then, one step per +//! Core block: MN_L1's operator sends a ProUpServTx (401) and MN_L2's owner a ProUpRegTx with a new +//! voting address (402), both keeping their operator key, which Core re-encodes in the basic +//! scheme; MN_L3's owner sets another operator key (403); MN_L4's operator revokes (404). +//! `399-404.json` is the same steps as one diff. + +use crate::config::PlatformConfig; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use dpp::block::block_info::BlockInfo; +use dpp::bls::SecretKey as BlsSecretKey; +use dpp::dashcore::address::NetworkUnchecked; +use dpp::dashcore::hashes::Hash; +use dpp::dashcore::{Address, ProTxHash}; +use dpp::dashcore_rpc::json::{DMNStateDiff, MasternodeListDiff, MasternodeListItem}; +use dpp::identifier::MasternodeIdentifiers; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{KeyType, Purpose}; +use dpp::prelude::Identifier; +use dpp::version::PlatformVersion; +use drive::grovedb::Transaction; +use rand::rngs::StdRng; +use rand::SeedableRng; +use serde_json::json; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::str::FromStr; + +/// The first Core height of the recording where DEPLOYMENT_V24 is active. +const V24_ACTIVATION_HEIGHT: u32 = 400; + +pub(super) const MN_L1: &str = "f18c25367cd4f2ba6e8cdf86d0ffcf2694b6206b75597fe1ab5b80b966dbdf4b"; +pub(super) const MN_L1_LEGACY_KEY: &str = "06ebb5ebd9079d73577e0900c9c10139d7d7213247e32b9b8823cd80e6a18e16a497ac57508e23b4a0e0510bfc880ad9"; +pub(super) const MN_L1_BASIC_KEY: &str = "86ebb5ebd9079d73577e0900c9c10139d7d7213247e32b9b8823cd80e6a18e16a497ac57508e23b4a0e0510bfc880ad9"; +pub(super) const MN_L2: &str = "02cc32acbd70020b192abdddfd8ab6d682b0e75c6cbe225f60fe6cf9da08859b"; +pub(super) const MN_L2_LEGACY_KEY: &str = "910633a672392e26860cbff612b28d84f71beb34c2092d015b0d522905d628d38d746c1088c654632c65c6a7f008095d"; +pub(super) const MN_L2_BASIC_KEY: &str = "b10633a672392e26860cbff612b28d84f71beb34c2092d015b0d522905d628d38d746c1088c654632c65c6a7f008095d"; +pub(super) const MN_L3: &str = "192a79c8bcd46a6f8500b3010d6f4145a6580a0a0294500dfbb96dbd4c2dd625"; +pub(super) const MN_L3_LEGACY_KEY: &str = "8e03b290379bfc0b6632e4eff4f45f40ff6888b49f0bfbca53f6e4f452c56b56a7c4f133dab94b0f61c05f3077317a9c"; +pub(super) const MN_L3_NEW_KEY: &str = "ac4121f7c123b5e265d59053f5988a4d2677a794f2ab8b9147b88436b47c690fdd44af7868ca9047199c346c9323b3bf"; +pub(super) const MN_L4: &str = "802390501809bee03accfa3742d62018a550324a918b85c6d106a51d946e8f2f"; +pub(super) const MN_L4_LEGACY_KEY: &str = "0c718ddf5a8e115c2a4ef8bf906e89a69cd54b29405010ca16047581d92f382835ca6bb558a9ee237ed2879e312a3eb9"; + +/// MN_L2's voting address before and after its ProUpRegTx. +const MN_L2_VOTING_ADDRESS: &str = "yhUTNhZjweRvHEJ43JUefpTuHavmDh3LjP"; +const MN_L2_NEW_VOTING_ADDRESS: &str = "yd59MzNMSWf5LdNE9qxZGrcFeR2BzjuqM2"; + +pub(super) fn bytes(hex: &str) -> Vec { + hex::decode(hex).expect("expected hex") +} + +pub(super) fn pro_tx_hash(hex: &str) -> ProTxHash { + ProTxHash::from_str(hex).expect("expected a pro tx hash") +} + +/// A recorded `protx listdiff` from `base_height` to `core_height`. +pub(super) fn recorded_list_diff(base_height: u32, core_height: u32) -> MasternodeListDiff { + let json = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key") + .join(format!("{base_height}-{core_height}.json")), + ) + .expect("expected to read the masternode list diff"); + serde_json::from_str(&json).expect("expected Core's masternode list diff") +} + +/// The recorded masternodes as Core lists them before V24, with their legacy-scheme keys. +pub(super) fn recorded_masternodes() -> BTreeMap { + recorded_list_diff(1, V24_ACTIVATION_HEIGHT - 1) + .added_mns + .into_iter() + .map(|masternode| (masternode.pro_tx_hash, masternode)) + .collect() +} + +/// A diff entry as Core prints it, with only the fields in `fields`. +pub(super) fn diff_entry(fields: serde_json::Value) -> DMNStateDiff { + serde_json::from_value(fields).expect("expected a diff entry") +} + +/// A basic-scheme public key whose sign bit is clear, and its negation. The 48 bytes of the first +/// also decode in the legacy scheme, to the second. +pub(super) fn key_and_negation(seed: u64) -> (Vec, Vec) { + let mut rng = StdRng::seed_from_u64(seed); + loop { + let secret = BlsSecretKey::random(&mut rng); + let basic = secret.public_key().to_bytes(); + if basic[0] & 0x20 == 0 { + // The negation of a key is the key of the negated secret, the group order minus it. + let mut negated_secret = [0u8; 32]; + let mut borrow = 0i16; + for (i, (order, secret)) in bytes(BLS12_381_GROUP_ORDER) + .iter() + .zip(secret.to_be_bytes().iter()) + .enumerate() + .rev() + { + let difference = i16::from(*order) - i16::from(*secret) - borrow; + borrow = i16::from(difference < 0); + negated_secret[i] = difference.rem_euclid(256) as u8; + } + let negation = BlsSecretKey::from_be_bytes(&negated_secret) + .expect("expected the negated secret to be a secret key") + .public_key(); + return (basic.to_vec(), negation.to_bytes().to_vec()); + } + } +} + +/// The order of the BLS12-381 groups, big-endian. +const BLS12_381_GROUP_ORDER: &str = + "73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001"; + +/// A platform whose Core reports `list_diffs` by Core height, with coinbase payload version 4 +/// from `v24_activation_height` on and version 3 before, as Core requires. +fn platform_with_core( + platform_version: &PlatformVersion, + list_diffs: BTreeMap, + v24_activation_height: u32, +) -> TempPlatform { + let mut platform = TestPlatformBuilder::new() + .with_config(PlatformConfig::default()) + .with_initial_protocol_version(platform_version.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + platform + .core_rpc + .expect_get_protx_diff_with_masternodes() + .returning(move |_, core_height| { + Ok(list_diffs + .get(&core_height) + .unwrap_or_else(|| panic!("no masternode list diff for core height {core_height}")) + .clone()) + }); + platform + .core_rpc + .expect_get_coinbase_payload_version() + .returning(move |core_height| { + Ok(Some(if core_height >= v24_activation_height { + 4 + } else { + 3 + })) + }); + platform +} + +/// The recorded diffs one Core height at a time, from the list at 399 to MN_L4's revocation. +fn recorded_steps() -> BTreeMap { + let mut steps = BTreeMap::from([( + V24_ACTIVATION_HEIGHT - 1, + recorded_list_diff(1, V24_ACTIVATION_HEIGHT - 1), + )]); + for core_height in V24_ACTIVATION_HEIGHT..=V24_ACTIVATION_HEIGHT + 4 { + steps.insert( + core_height, + recorded_list_diff(core_height - 1, core_height), + ); + } + steps +} + +/// Runs one masternode list update per Core height of `list_diffs`, in order, as Drive runs one +/// per block, the first one from scratch as at init chain. With `reload_after`, the platform state +/// is stored and fetched back after that height, as a restarted node continues. Returns the state +/// after the last update. +fn run_core_heights( + platform: &TempPlatform, + transaction: &Transaction, + platform_version: &PlatformVersion, + core_heights: impl IntoIterator, + reload_after: Option, +) -> PlatformState { + let mut committed: Option = None; + let mut block_state = platform.state.load().as_ref().clone(); + for (index, core_height) in core_heights.into_iter().enumerate() { + let block_height = index as u64 + 1; + platform + .update_masternode_list( + committed.as_ref(), + &mut block_state, + core_height, + index == 0, + &BlockInfo { + height: block_height, + core_height, + time_ms: block_height, + ..Default::default() + }, + transaction, + platform_version, + ) + .expect("expected to apply Core's masternode list diff"); + if reload_after == Some(core_height) { + platform + .store_platform_state(&block_state, Some(transaction), platform_version) + .expect("expected to store the platform state"); + block_state = Platform::::fetch_platform_state( + &platform.drive, + Some(transaction), + platform_version, + ) + .expect("expected to fetch the platform state") + .expect("expected a stored platform state"); + } + committed = Some(block_state.clone()); + } + block_state +} + +/// The (key type, purpose, data, disabled) of every key of an identity; `None` without one. +fn identity_keys( + platform: &TempPlatform, + transaction: &Transaction, + platform_version: &PlatformVersion, + identity_id: Identifier, +) -> Option, bool)>> { + platform + .drive + .fetch_full_identity(identity_id.to_buffer(), Some(transaction), platform_version) + .expect("expected to fetch an identity") + .map(|identity| { + identity + .public_keys() + .values() + .map(|key| { + ( + key.key_type(), + key.purpose(), + key.data().to_vec(), + key.is_disabled(), + ) + }) + .collect() + }) +} + +fn operator_identifier(pro_tx_hash: &ProTxHash, key: &[u8]) -> Identifier { + Identifier::create_operator_identifier(&pro_tx_hash.to_byte_array(), key) +} + +/// Whether the identity has a BLS key with `data`, and whether that key is disabled. +fn bls_key_disabled(keys: &[(KeyType, Purpose, Vec, bool)], data: &[u8]) -> Option { + keys.iter() + .find(|(key_type, _, key_data, _)| *key_type == KeyType::BLS12_381 && key_data == data) + .map(|(_, _, _, disabled)| *disabled) +} + +fn voting_key_hash(address: &str) -> [u8; 20] { + Address::::from_str(address) + .expect("expected an address") + .payload_to_vec() + .try_into() + .expect("expected a 20-byte key hash") +} + +/// Once V24 is active, Core re-encodes a legacy-scheme operator key in the basic scheme when the +/// masternode's state version is raised, and reports the new bytes as a changed key. The key is +/// the same, so the masternode keeps its operator identity: the identity derived from the stored +/// legacy bytes keeps its keys enabled, the list keeps those bytes, and no identity is created for +/// the basic bytes. The other fields of the same entry apply as always, and in the same diff a +/// real key change and a revocation switch operator identities as always. Checked with the +/// recorded steps one Core height per block, and with all of them in one diff. +#[test] +fn should_keep_the_operator_identity_of_a_legacy_key_core_re_encodes() { + for platform_version in [ + PlatformVersion::get(13).expect("expected protocol version 13"), + PlatformVersion::latest(), + ] { + let version = platform_version.protocol_version; + let one_window = BTreeMap::from([ + ( + V24_ACTIVATION_HEIGHT - 1, + recorded_list_diff(1, V24_ACTIVATION_HEIGHT - 1), + ), + ( + V24_ACTIVATION_HEIGHT + 4, + recorded_list_diff(V24_ACTIVATION_HEIGHT - 1, V24_ACTIVATION_HEIGHT + 4), + ), + ]); + for (windows, list_diffs) in [ + ("one per Core height", recorded_steps()), + ("one diff", one_window), + ] { + let core_heights: Vec = list_diffs.keys().copied().collect(); + let platform = platform_with_core(platform_version, list_diffs, V24_ACTIVATION_HEIGHT); + let transaction = platform.drive.grove.start_transaction(); + let state = run_core_heights( + &platform, + &transaction, + platform_version, + core_heights, + None, + ); + let keys = + |identity_id| identity_keys(&platform, &transaction, platform_version, identity_id); + let context = format!("PV{version}, {windows}"); + + for (masternode, legacy_key, basic_key) in [ + (MN_L1, MN_L1_LEGACY_KEY, MN_L1_BASIC_KEY), + (MN_L2, MN_L2_LEGACY_KEY, MN_L2_BASIC_KEY), + ] { + let masternode = pro_tx_hash(masternode); + assert_eq!( + state.full_masternode_list()[&masternode] + .state + .pub_key_operator, + bytes(legacy_key), + "{context}: the stored operator key of {masternode} keeps its legacy bytes" + ); + let operator_keys = keys(operator_identifier(&masternode, &bytes(legacy_key))) + .expect("expected the operator identity derived from the legacy bytes"); + assert!( + operator_keys.iter().all(|(.., disabled)| !disabled), + "{context}: every key of the operator identity of {masternode} stays enabled: {operator_keys:?}" + ); + assert_eq!( + bls_key_disabled(&operator_keys, &bytes(legacy_key)), + Some(false), + "{context}: {masternode}" + ); + assert_eq!( + keys(operator_identifier(&masternode, &bytes(basic_key))), + None, + "{context}: no operator identity for the re-encoded bytes of {masternode}" + ); + } + + // MN_L2 keeps its operator payout key, and the new voting address of the same + // ProUpRegTx is applied, with its voter identity. + let mn_l2 = pro_tx_hash(MN_L2); + assert!( + keys(operator_identifier(&mn_l2, &bytes(MN_L2_LEGACY_KEY))) + .expect("expected MN_L2's operator identity") + .iter() + .any(|(_, purpose, _, disabled)| *purpose == Purpose::TRANSFER && !disabled), + "{context}: MN_L2 keeps its operator payout key" + ); + let new_voting_key_hash = voting_key_hash(MN_L2_NEW_VOTING_ADDRESS); + assert_ne!(new_voting_key_hash, voting_key_hash(MN_L2_VOTING_ADDRESS)); + assert_eq!( + state.full_masternode_list()[&mn_l2].state.voting_address, + new_voting_key_hash, + "{context}: MN_L2's new voting address is applied" + ); + assert!( + keys(Identifier::create_voter_identifier( + &mn_l2.to_byte_array(), + &new_voting_key_hash, + )) + .is_some(), + "{context}: MN_L2's new voting address has its voter identity" + ); + + // MN_L3 set another operator key: the operator identity switches as always. + let mn_l3 = pro_tx_hash(MN_L3); + assert_eq!( + state.full_masternode_list()[&mn_l3].state.pub_key_operator, + bytes(MN_L3_NEW_KEY), + "{context}" + ); + assert_eq!( + bls_key_disabled( + &keys(operator_identifier(&mn_l3, &bytes(MN_L3_LEGACY_KEY))) + .expect("expected MN_L3's previous operator identity"), + &bytes(MN_L3_LEGACY_KEY) + ), + Some(true), + "{context}: MN_L3's previous operator key is disabled" + ); + assert_eq!( + bls_key_disabled( + &keys(operator_identifier(&mn_l3, &bytes(MN_L3_NEW_KEY))) + .expect("expected MN_L3's new operator identity"), + &bytes(MN_L3_NEW_KEY) + ), + Some(false), + "{context}" + ); + + // MN_L4's operator revoked: Core reports the null key, a key change as always. + assert_eq!( + bls_key_disabled( + &keys(operator_identifier( + &pro_tx_hash(MN_L4), + &bytes(MN_L4_LEGACY_KEY) + )) + .expect("expected MN_L4's previous operator identity"), + &bytes(MN_L4_LEGACY_KEY) + ), + Some(true), + "{context}: MN_L4's revoked operator key is disabled" + ); + } + } +} + +/// Platform keeps a re-encoded key only where Core produces one, which Core's coinbase marks: +/// with the coinbase payload version below 4 at the block's Core height, the same recorded diff +/// is a key change, as it always was. +#[test] +fn should_switch_the_operator_identity_on_a_re_encoded_key_while_v24_is_not_active() { + for platform_version in [ + PlatformVersion::get(13).expect("expected protocol version 13"), + PlatformVersion::latest(), + ] { + let version = platform_version.protocol_version; + let platform = platform_with_core(platform_version, recorded_steps(), u32::MAX); + let transaction = platform.drive.grove.start_transaction(); + let state = run_core_heights( + &platform, + &transaction, + platform_version, + V24_ACTIVATION_HEIGHT - 1..=V24_ACTIVATION_HEIGHT + 1, + None, + ); + let mn_l1 = pro_tx_hash(MN_L1); + assert_eq!( + state.full_masternode_list()[&mn_l1].state.pub_key_operator, + bytes(MN_L1_BASIC_KEY), + "PV{version}" + ); + assert_eq!( + bls_key_disabled( + &identity_keys( + &platform, + &transaction, + platform_version, + operator_identifier(&mn_l1, &bytes(MN_L1_LEGACY_KEY)) + ) + .expect("expected MN_L1's previous operator identity"), + &bytes(MN_L1_LEGACY_KEY) + ), + Some(true), + "PV{version}" + ); + assert!(identity_keys( + &platform, + &transaction, + platform_version, + operator_identifier(&mn_l1, &bytes(MN_L1_BASIC_KEY)) + ) + .is_some()); + } +} + +/// After the recorded steps, MN_L2's owner sets another operator key and MN_L1 is removed. Both +/// reach the operator identity kept on the re-encode: the key change disables its BLS key and +/// creates the new identity, the removal disables its BLS key. A node that stored and reloaded its +/// platform state after the first re-encode ends with the same state as one that did not. +#[test] +fn should_reach_the_kept_operator_identity_after_a_re_encode_and_a_restart() { + let mn_l2_new_key = key_and_negation(11).0; + let after_recording = |list_diffs: &mut BTreeMap| { + let key_change_height = V24_ACTIVATION_HEIGHT + 5; + list_diffs.insert( + key_change_height, + MasternodeListDiff { + base_height: key_change_height - 1, + block_height: key_change_height, + added_mns: vec![], + removed_mns: vec![], + updated_mns: vec![( + pro_tx_hash(MN_L2), + diff_entry(json!({ "pubKeyOperator": hex::encode(&mn_l2_new_key) })), + )], + }, + ); + list_diffs.insert( + key_change_height + 1, + MasternodeListDiff { + base_height: key_change_height, + block_height: key_change_height + 1, + added_mns: vec![], + removed_mns: vec![pro_tx_hash(MN_L1)], + updated_mns: vec![], + }, + ); + }; + for platform_version in [ + PlatformVersion::get(13).expect("expected protocol version 13"), + PlatformVersion::latest(), + ] { + let version = platform_version.protocol_version; + let mut root_hashes = vec![]; + for reload_after in [None, Some(V24_ACTIVATION_HEIGHT + 1)] { + let mut list_diffs = recorded_steps(); + after_recording(&mut list_diffs); + let core_heights: Vec = list_diffs.keys().copied().collect(); + let platform = platform_with_core(platform_version, list_diffs, V24_ACTIVATION_HEIGHT); + let transaction = platform.drive.grove.start_transaction(); + let state = run_core_heights( + &platform, + &transaction, + platform_version, + core_heights, + reload_after, + ); + let keys = + |identity_id| identity_keys(&platform, &transaction, platform_version, identity_id); + let context = format!("PV{version}, reload after {reload_after:?}"); + + let mn_l2 = pro_tx_hash(MN_L2); + assert_eq!( + bls_key_disabled( + &keys(operator_identifier(&mn_l2, &bytes(MN_L2_LEGACY_KEY))) + .expect("expected MN_L2's kept operator identity"), + &bytes(MN_L2_LEGACY_KEY) + ), + Some(true), + "{context}: MN_L2's key change disables the BLS key of the identity it kept" + ); + assert_eq!( + bls_key_disabled( + &keys(operator_identifier(&mn_l2, &mn_l2_new_key)) + .expect("expected MN_L2's new operator identity"), + &mn_l2_new_key + ), + Some(false), + "{context}" + ); + + let mn_l1 = pro_tx_hash(MN_L1); + assert!(!state.full_masternode_list().contains_key(&mn_l1)); + assert_eq!( + bls_key_disabled( + &keys(operator_identifier(&mn_l1, &bytes(MN_L1_LEGACY_KEY))) + .expect("expected MN_L1's operator identity"), + &bytes(MN_L1_LEGACY_KEY) + ), + Some(true), + "{context}: removing MN_L1 disables the BLS key of the identity it kept" + ); + assert_eq!( + keys(operator_identifier(&mn_l1, &bytes(MN_L1_BASIC_KEY))), + None, + "{context}: MN_L1 never had a second operator identity" + ); + root_hashes.push( + platform + .drive + .grove + .root_hash(Some(&transaction), &platform_version.drive.grove_version) + .value + .expect("expected a root hash"), + ); + } + assert_eq!( + root_hashes[0], root_hashes[1], + "PV{version}: a restart after the re-encode does not change the outcome" + ); + } +} + +/// The masternode for the synthetic steps: MN_L1 as recorded, with operator key `key`. +fn masternode_with_key(key: Vec) -> MasternodeListItem { + let mut masternode = recorded_masternodes() + .remove(&pro_tx_hash(MN_L1)) + .expect("expected MN_L1 in the recording"); + masternode.state.pub_key_operator = key; + masternode +} + +/// Core reports, one Core height each from 11, operator key `steps` for MN_L1 listed at 10 with +/// operator key `key`; V24 is active from `v24_activation_height`. Returns the platform, with the +/// updates committed, and the state after the last step. +fn run_operator_key_steps( + platform_version: &PlatformVersion, + key: Vec, + steps: Vec>, + v24_activation_height: u32, +) -> (TempPlatform, PlatformState) { + let listed = masternode_with_key(key); + let mut list_diffs = BTreeMap::from([( + 10, + MasternodeListDiff { + base_height: 1, + block_height: 10, + added_mns: vec![listed.clone()], + removed_mns: vec![], + updated_mns: vec![], + }, + )]); + for (core_height, step) in (11..).zip(steps) { + list_diffs.insert( + core_height, + MasternodeListDiff { + base_height: core_height - 1, + block_height: core_height, + added_mns: vec![], + removed_mns: vec![], + updated_mns: vec![( + listed.pro_tx_hash, + diff_entry(json!({ "pubKeyOperator": hex::encode(step) })), + )], + }, + ); + } + let core_heights: Vec = list_diffs.keys().copied().collect(); + let platform = platform_with_core(platform_version, list_diffs, v24_activation_height); + let transaction = platform.drive.grove.start_transaction(); + let state = run_core_heights( + &platform, + &transaction, + platform_version, + core_heights, + None, + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + (platform, state) +} + +/// Before V24 Platform applies every operator key Core reports exactly as it always has, so the +/// blocks it has executed replay to the same result. That holds for the keys the rules for +/// re-encoded keys would otherwise keep: one whose bytes read in the legacy scheme give the stored +/// key's negation, and one whose bytes equal the stored ones. +#[test] +fn should_apply_every_reported_operator_key_before_v24() { + for platform_version in [ + PlatformVersion::get(13).expect("expected protocol version 13"), + PlatformVersion::latest(), + ] { + let version = platform_version.protocol_version; + let (key, negation) = key_and_negation(7); + let mn_l1 = pro_tx_hash(MN_L1); + + let (platform, state) = + run_operator_key_steps(platform_version, key.clone(), vec![negation.clone()], 100); + let transaction = platform.drive.grove.start_transaction(); + let keys = + |identity_id| identity_keys(&platform, &transaction, platform_version, identity_id); + assert_eq!( + state.full_masternode_list()[&mn_l1].state.pub_key_operator, + negation, + "PV{version}" + ); + assert_eq!( + bls_key_disabled( + &keys(operator_identifier(&mn_l1, &key)).expect("expected the previous identity"), + &key + ), + Some(true), + "PV{version}: the previous operator key is disabled" + ); + assert!(keys(operator_identifier(&mn_l1, &negation)).is_some()); + + let (platform, _) = + run_operator_key_steps(platform_version, key.clone(), vec![key.clone()], 100); + let transaction = platform.drive.grove.start_transaction(); + assert_eq!( + bls_key_disabled( + &identity_keys( + &platform, + &transaction, + platform_version, + operator_identifier(&mn_l1, &key) + ) + .expect("expected the operator identity"), + &key + ), + Some(true), + "PV{version}: a reported key equal to the stored one is applied as a key change" + ); + } +} + +/// Once V24 is active at the block's Core height, a reported key whose bytes are the basic +/// encoding of the stored bytes read in the legacy scheme keeps the operator identity, and so does +/// a later key whose bytes equal the stored bytes. Such a pair is a key and its negation: whoever +/// holds the secret of one holds the secret of the other. V24 activates at the first step, so +/// the Core height asked about is the block's own. +#[test] +fn should_keep_the_operator_identity_for_a_negated_key_once_v24_is_active() { + for platform_version in [ + PlatformVersion::get(13).expect("expected protocol version 13"), + PlatformVersion::latest(), + ] { + let version = platform_version.protocol_version; + let (key, negation) = key_and_negation(7); + let mn_l1 = pro_tx_hash(MN_L1); + let (platform, state) = run_operator_key_steps( + platform_version, + key.clone(), + vec![negation.clone(), key.clone()], + 11, + ); + let transaction = platform.drive.grove.start_transaction(); + assert_eq!( + state.full_masternode_list()[&mn_l1].state.pub_key_operator, + key, + "PV{version}: the stored key keeps its bytes" + ); + let operator_keys = identity_keys( + &platform, + &transaction, + platform_version, + operator_identifier(&mn_l1, &key), + ) + .expect("expected the operator identity"); + assert!( + operator_keys.iter().all(|(.., disabled)| !disabled), + "PV{version}: {operator_keys:?}" + ); + assert_eq!( + identity_keys( + &platform, + &transaction, + platform_version, + operator_identifier(&mn_l1, &negation) + ), + None, + "PV{version}" + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/mod.rs index d0ad9c9e79e..908313e23e1 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/mod.rs @@ -1,4 +1,347 @@ mod v0; mod v1; -// Private helpers are selected by the update_masternode_list generation. +// Private helpers are selected by the update_masternode_list generation, except +// `drop_unchanged_operator_keys`, which v0 and v1 share. + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::bls::PublicKey as BlsPublicKey; +use dpp::dashcore::ProTxHash; +use dpp::dashcore_rpc::dashcore_rpc_json::{DMNStateDiff, MasternodeListItem}; +use std::collections::BTreeMap; + +/// The coinbase payload version Dash Core requires while DEPLOYMENT_V24 is active +/// (`MERKLE_ROOT_ASSETUNLOCKS`). Core requires it exactly at the blocks where V24 and V20 are +/// active and refuses it at every other block; V20 is active on every chain Platform runs on. +const V24_COINBASE_PAYLOAD_VERSION: u16 = 4; + +/// The first byte of the point at infinity, which has the same 48 bytes in both schemes. +const BLS_INFINITY_FIRST_BYTE: u8 = 0xc0; +/// The legacy scheme's only flag: bit 7 of the first byte is the sign of y. Bits 5 and 6 are +/// clear in every legacy encoding but the point at infinity. +const LEGACY_SIGN_BIT: u8 = 0x80; +const LEGACY_CLEAR_BITS: u8 = 0x60; +/// The basic scheme flags a compressed point in bit 7 and the sign of y in bit 5. +const BASIC_COMPRESSION_BIT: u8 = 0x80; +const BASIC_SIGN_BIT: u8 = 0x20; + +/// Whether `new` is the basic-scheme encoding of the BLS public key that `old` encodes in the +/// legacy scheme. The verdict decides whether a masternode keeps its operator identity, so the +/// encodings it accepts are pinned by tests. +/// +/// The legacy flags are moved to their basic-scheme positions and the result must decode as a +/// basic-scheme key: an x coordinate below the field modulus, a point on the curve and in the +/// prime-order subgroup, or the point at infinity. +fn is_basic_re_encoding_of_legacy_key(old: &[u8], new: &[u8]) -> bool { + let Ok(mut basic) = <[u8; 48]>::try_from(old) else { + return false; + }; + if basic[0] != BLS_INFINITY_FIRST_BYTE { + let negative = basic[0] & LEGACY_SIGN_BIT != 0; + basic[0] &= !LEGACY_SIGN_BIT; + if basic[0] & LEGACY_CLEAR_BITS != 0 { + return false; + } + basic[0] |= BASIC_COMPRESSION_BIT; + if negative { + basic[0] |= BASIC_SIGN_BIT; + } + } + BlsPublicKey::try_from(basic.as_slice()).is_ok_and(|key| key.to_bytes().as_slice() == new) +} + +impl Platform +where + C: CoreRPCLike, +{ + /// Clears `pub_key_operator` in each entry of `updated_mns` whose reported key is the key + /// `masternode_list` already holds for that masternode: the same bytes, or the basic-scheme + /// encoding of the key the stored bytes encode in the legacy scheme. + /// + /// Once DEPLOYMENT_V24 is active, Dash Core re-serializes a legacy-scheme operator key in the + /// basic scheme when it raises a masternode's state version, and reports the new bytes as a + /// changed key. The key is the same. Platform keeps the stored bytes, which its operator + /// identity's id is derived from, so the masternode keeps that identity. While V24 is not + /// active at `core_block_height`, every entry stays as Core reports it, so blocks at earlier + /// Core heights execute as they always have. + /// + /// The stored operator key therefore depends on the list's history: a list rebuilt from + /// Core's full listing holds Core's bytes instead. + /// + /// Core is asked whether V24 is active only when an entry would be cleared, at most once. + /// + /// Used by `update_state_masternode_list` v0 and v1, so a change of behaviour here needs new + /// generations of both. + fn drop_unchanged_operator_keys( + &self, + masternode_list: &BTreeMap, + core_block_height: u32, + updated_mns: &mut [(ProTxHash, DMNStateDiff)], + ) -> Result<(), Error> { + let mut v24_active = false; + for (pro_tx_hash, state_diff) in updated_mns.iter_mut() { + let (Some(masternode), Some(new_key)) = ( + masternode_list.get(pro_tx_hash), + state_diff.pub_key_operator.as_deref(), + ) else { + continue; + }; + let stored_key = masternode.state.pub_key_operator.as_slice(); + if new_key != stored_key && !is_basic_re_encoding_of_legacy_key(stored_key, new_key) { + continue; + } + if !v24_active { + // Every block at a Core height Platform follows carries a coinbase payload. + let version = self + .core_rpc + .get_coinbase_payload_version(core_block_height)? + .ok_or_else(|| { + ExecutionError::DashCoreBadResponseError(format!( + "the coinbase of the block at core height {core_block_height} has no payload" + )) + })?; + if version < V24_COINBASE_PAYLOAD_VERSION { + return Ok(()); + } + v24_active = true; + } + state_diff.pub_key_operator = None; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{is_basic_re_encoding_of_legacy_key, V24_COINBASE_PAYLOAD_VERSION}; + use crate::error::execution::ExecutionError; + use crate::error::Error; + use crate::execution::platform_events::core_based_updates::update_masternode_list::operator_key_encoding_tests::{ + bytes, diff_entry, key_and_negation, pro_tx_hash, recorded_masternodes, MN_L1, + MN_L1_BASIC_KEY, MN_L1_LEGACY_KEY, MN_L2, MN_L2_BASIC_KEY, MN_L2_LEGACY_KEY, MN_L3, + MN_L3_NEW_KEY, MN_L4, + }; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::dashcore::ProTxHash; + use dpp::dashcore_rpc::dashcore_rpc_json::DMNStateDiff; + use dpp::dashcore_rpc::Error as CoreRpcError; + use serde_json::json; + use std::str::FromStr; + + /// An operator key as Dash Core v24.0.0-rc.3 printed it before and after re-encoding it, with + /// the legacy sign bit clear and set. + const CORE_RE_ENCODINGS: [(&str, &str); 2] = [ + (MN_L1_LEGACY_KEY, MN_L1_BASIC_KEY), + (MN_L2_LEGACY_KEY, MN_L2_BASIC_KEY), + ]; + + /// The BLS12-381 base field modulus, big-endian: no field element, so no x coordinate. + const FIELD_MODULUS: &str = "1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab"; + + #[test] + fn should_recognise_the_basic_encoding_of_a_legacy_key_as_core_prints_it() { + for (i, (legacy, _)) in CORE_RE_ENCODINGS.iter().enumerate() { + for (j, (_, basic)) in CORE_RE_ENCODINGS.iter().enumerate() { + assert_eq!( + is_basic_re_encoding_of_legacy_key(&bytes(legacy), &bytes(basic)), + i == j, + "{legacy} -> {basic}" + ); + } + } + for (legacy, basic) in CORE_RE_ENCODINGS { + let (legacy, basic) = (bytes(legacy), bytes(basic)); + assert!(!is_basic_re_encoding_of_legacy_key(&basic, &legacy)); + assert!(!is_basic_re_encoding_of_legacy_key(&legacy, &legacy)); + assert!(!is_basic_re_encoding_of_legacy_key(&basic, &basic)); + assert!(!is_basic_re_encoding_of_legacy_key(&legacy, &basic[..47])); + } + } + + /// Bytes that are not a legacy-scheme public key never match. + #[test] + fn should_not_recognise_bytes_that_are_not_a_legacy_key() { + let basic = bytes(MN_L1_BASIC_KEY); + // The null key Core prints for a revoked operator; it reads as a curve point outside the + // prime-order subgroup. + let null_key = vec![0u8; 48]; + let mut sign_set_x_zero = vec![0u8; 48]; + sign_set_x_zero[0] = 0x80; + let mut bit_6_set = bytes(MN_L1_LEGACY_KEY); + bit_6_set[0] |= 0x40; + let mut bit_5_set = bytes(MN_L1_LEGACY_KEY); + bit_5_set[0] |= 0x20; + let mut too_long = bytes(MN_L1_LEGACY_KEY); + too_long.push(0); + for old in [ + null_key, + sign_set_x_zero, + bytes(FIELD_MODULUS), + bit_6_set, + bit_5_set, + vec![], + bytes(MN_L1_LEGACY_KEY)[..47].to_vec(), + too_long, + ] { + for new in [basic.clone(), old.clone()] { + assert!( + !is_basic_re_encoding_of_legacy_key(&old, &new), + "{} -> {}", + hex::encode(&old), + hex::encode(&new) + ); + } + } + } + + /// The point at infinity has one encoding in both schemes, so it matches only itself, where + /// the bytes are unchanged anyway. + #[test] + fn should_recognise_the_point_at_infinity_only_as_itself() { + let mut infinity = vec![0u8; 48]; + infinity[0] = 0xc0; + assert!(is_basic_re_encoding_of_legacy_key(&infinity, &infinity)); + assert!(!is_basic_re_encoding_of_legacy_key( + &infinity, + &bytes(MN_L1_BASIC_KEY) + )); + } + + /// The bytes alone do not tell which scheme a stored key is in: a basic-scheme key whose sign + /// bit is clear also decodes in the legacy scheme, to its negation. So the predicate holds for + /// a key and its negation too; whoever holds the secret of one holds the secret of the other. + #[test] + fn should_hold_for_a_basic_key_with_a_clear_sign_bit_and_its_negation() { + let (key, negation) = key_and_negation(7); + assert_eq!(key[0] & 0x20, 0); + assert!(is_basic_re_encoding_of_legacy_key(&key, &negation)); + // The negation's sign bit is set, so it does not decode in the legacy scheme. + assert!(!is_basic_re_encoding_of_legacy_key(&negation, &key)); + } + + /// One entry of each kind, as one Core diff: MN_L1's key re-encoded with a voting address + /// change, MN_L2's key unchanged with a service change, MN_L3's key replaced and MN_L4's key + /// revoked. + fn updated_mns() -> Vec<(ProTxHash, DMNStateDiff)> { + vec![ + ( + pro_tx_hash(MN_L1), + diff_entry(json!({ + "pubKeyOperator": MN_L1_BASIC_KEY, + "votingAddress": "yd59MzNMSWf5LdNE9qxZGrcFeR2BzjuqM2", + })), + ), + ( + pro_tx_hash(MN_L2), + diff_entry(json!({ + "pubKeyOperator": MN_L2_LEGACY_KEY, + "service": "127.0.0.1:30009", + })), + ), + ( + pro_tx_hash(MN_L3), + diff_entry(json!({ "pubKeyOperator": MN_L3_NEW_KEY })), + ), + ( + pro_tx_hash(MN_L4), + diff_entry(json!({ "pubKeyOperator": "00".repeat(48) })), + ), + ] + } + + /// While V24 is active at the block's Core height, the re-encoded and the unchanged key are + /// cleared, and nothing else: their other fields, the replaced key and the revocation stay. + /// Before V24 nothing changes. Core is asked once, about the block's Core height. + #[test] + fn should_clear_unchanged_operator_keys_only_while_v24_is_active() { + let masternodes = recorded_masternodes(); + for (coinbase_payload_version, cleared) in [ + (V24_COINBASE_PAYLOAD_VERSION - 1, false), + (V24_COINBASE_PAYLOAD_VERSION, true), + ] { + let mut platform = TestPlatformBuilder::new().build_with_mock_rpc(); + platform + .core_rpc + .expect_get_coinbase_payload_version() + .withf(|core_height| *core_height == 401) + .times(1) + .returning(move |_| Ok(Some(coinbase_payload_version))); + let mut entries = updated_mns(); + platform + .drop_unchanged_operator_keys(&masternodes, 401, &mut entries) + .expect("expected to normalize the diff"); + let mut expected = updated_mns(); + if cleared { + expected[0].1.pub_key_operator = None; + expected[1].1.pub_key_operator = None; + } + assert_eq!( + entries, expected, + "coinbase payload version {coinbase_payload_version}" + ); + } + } + + /// A Core error, or a coinbase without a payload, fails the update instead of being read as + /// V24 not being active, and leaves the entries as reported. + #[test] + fn should_fail_when_core_cannot_say_whether_v24_is_active() { + let masternodes = recorded_masternodes(); + for answer in [ + Err(CoreRpcError::UnexpectedStructure("refused".to_string())), + Ok(None), + ] { + let mut platform = TestPlatformBuilder::new().build_with_mock_rpc(); + let expect_none = answer.is_ok(); + platform + .core_rpc + .expect_get_coinbase_payload_version() + .times(1) + .return_once(move |_| answer); + let mut entries = updated_mns(); + let result = platform.drop_unchanged_operator_keys(&masternodes, 401, &mut entries); + if expect_none { + assert!(matches!( + result, + Err(Error::Execution(ExecutionError::DashCoreBadResponseError( + _ + ))) + )); + } else { + assert!(result.is_err()); + } + assert_eq!(entries, updated_mns()); + } + } + + /// A real key change, an entry without a key and an entry for a masternode Platform does not + /// list are kept as reported, without asking Core. + #[test] + fn should_keep_other_entries_without_asking_core() { + let masternodes = recorded_masternodes(); + let platform = TestPlatformBuilder::new().build_with_mock_rpc(); + let unlisted = ProTxHash::from_str(&"11".repeat(32)).expect("expected a pro tx hash"); + let mut entries = vec![ + ( + pro_tx_hash(MN_L1), + diff_entry(json!({ "pubKeyOperator": MN_L2_BASIC_KEY })), + ), + ( + pro_tx_hash(MN_L2), + diff_entry(json!({ "service": "127.0.0.1:30009" })), + ), + ( + unlisted, + diff_entry(json!({ "pubKeyOperator": MN_L1_BASIC_KEY })), + ), + ]; + let reported = entries.clone(); + platform + .drop_unchanged_operator_keys(&masternodes, 401, &mut entries) + .expect("expected to normalize the diff"); + assert_eq!(entries, reported); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs index e5518c423d3..50a91e08f0b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v0/mod.rs @@ -123,10 +123,21 @@ where Some(state_core_height) }; - let masternode_diff = self + let mut masternode_diff = self .core_rpc .get_protx_diff_with_masternodes(previous_core_height, core_block_height)?; + if !start_from_scratch { + // Protocol versions 1 to 13 select this generation. The call changes no entry unless + // DEPLOYMENT_V24 is active at `core_block_height`, so blocks at earlier Core heights + // execute unchanged. + self.drop_unchanged_operator_keys( + state.full_masternode_list(), + core_block_height, + &mut masternode_diff.updated_mns, + )?; + } + let MasternodeListDiff { added_mns, removed_mns, diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v1/mod.rs index af7b64bbd84..16275542f8c 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_based_updates/update_masternode_list/update_state_masternode_list/v1/mod.rs @@ -125,10 +125,18 @@ where Some(state_core_height) }; - let masternode_diff = self + let mut masternode_diff = self .core_rpc .get_protx_diff_with_masternodes(previous_core_height, core_block_height)?; + if !start_from_scratch { + self.drop_unchanged_operator_keys( + state.full_masternode_list(), + core_block_height, + &mut masternode_diff.updated_mns, + )?; + } + let MasternodeListDiff { added_mns, removed_mns, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs index 9ca6601d59a..5059888c7b4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs @@ -2564,6 +2564,7 @@ mod tests { use dpp::dashcore::{BlockHash, OutPoint, Transaction}; use dpp::dashcore_rpc::json::GetRawTransactionResult; use dpp::fee::Credits; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dpp::identity::state_transition::asset_lock_proof::{ AssetLockProof, InstantAssetLockProof, @@ -2685,6 +2686,9 @@ mod tests { /// The same keys with a critical key in place of the master key: a key structure /// failure, as an identity needs a master key WithoutAMasterKey, + /// The valid keys, except that the hash key's data has this many bytes instead of + /// the 20 bytes of a hash + WithAHashKeyOfSize(usize), } /// How an identity create proves its asset lock. @@ -2734,11 +2738,13 @@ mod tests { let mut rng = StdRng::seed_from_u64(567); let (first_key, first_private_key) = match keys { - Keys::Valid => IdentityPublicKey::random_ecdsa_master_authentication_key( - 0, - Some(58), - platform_version, - ), + Keys::Valid | Keys::WithAHashKeyOfSize(_) => { + IdentityPublicKey::random_ecdsa_master_authentication_key( + 0, + Some(58), + platform_version, + ) + } Keys::WithoutAMasterKey => { IdentityPublicKey::random_ecdsa_critical_level_authentication_key( 0, @@ -2759,7 +2765,7 @@ mod tests { .expect("expected to get key pair"); signer.add_identity_public_key(second_key.clone(), second_private_key); - let (hash_key, _) = IdentityPublicKey::random_key_with_known_attributes( + let (mut hash_key, _) = IdentityPublicKey::random_key_with_known_attributes( 2, &mut rng, Purpose::AUTHENTICATION, @@ -2769,6 +2775,9 @@ mod tests { platform_version, ) .expect("expected to get key pair"); + if let Keys::WithAHashKeyOfSize(size) = keys { + hash_key.set_data(vec![0x5a; size].into()); + } let (_, pk) = ECDSA_SECP256K1 .random_public_and_private_key_data(&mut rng, platform_version) @@ -3236,6 +3245,96 @@ mod tests { ); } + /// A hash key's data is the hash itself, so from protocol version 14 data that is not 20 + /// bytes fails the key structure check: check_tx refuses it with a consensus error, and + /// a block charges it to the asset lock like any other key structure failure, as the + /// asset lock key signs the keys an identity create registers. + #[tokio::test] + async fn should_charge_the_asset_lock_for_an_identity_create_with_a_hash_key_of_the_wrong_size( + ) { + let platform_version = PlatformVersion::latest(); + for size in [0, 19, 21] { + let platform = new_platform(platform_version.protocol_version); + let registration = + registration(Keys::WithAHashKeyOfSize(size), platform_version).await; + + let errors = check_tx( + &platform, + ®istration.transition, + CheckTxLevel::FirstTimeCheck, + ); + assert_matches!( + errors.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidIdentityPublicKeyDataError(error) + )] if error.public_key_id() == 2, + "{size} bytes: {errors:?}" + ); + + let charged = + process_block(&platform, &[®istration.transition], platform_version); + assert_matches!( + charged.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError( + BasicError::InvalidIdentityPublicKeyDataError(_) + ), + .. + }], + "{size} bytes: {:?}", + charged.execution_results() + ); + let charged_credits = charged.aggregated_fees().processing_fee; + assert!( + charged_credits + > platform_version + .drive_abci + .validation_and_processing + .penalties + .validation_of_added_keys_structure_failure, + "{size} bytes: charged {charged_credits}" + ); + let StoredAssetLockInfo::PartiallyConsumed(asset_lock_value) = + asset_lock_info(&platform, ®istration, platform_version) + else { + panic!("{size} bytes: expected the asset lock to be partially consumed"); + }; + assert_eq!( + asset_lock_value.remaining_credit_value(), + asset_lock_value.initial_credit_value() - charged_credits, + "{size} bytes" + ); + assert_eq!( + identity_balance(&platform, ®istration, platform_version), + None, + "{size} bytes" + ); + } + } + + /// Protocol version 13 does not check the size of a hash key's data, so such a key fails + /// once its hash is taken, as an internal error that keeps the transition out of every + /// block; that history replays unchanged. + #[tokio::test] + async fn should_fail_an_identity_create_with_a_hash_key_of_the_wrong_size_internally_at_protocol_version_13( + ) { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let platform = new_platform(platform_version.protocol_version); + let registration = registration(Keys::WithAHashKeyOfSize(19), platform_version).await; + + let failed = process_block(&platform, &[®istration.transition], platform_version); + assert_matches!( + failed.execution_results().as_slice(), + [StateTransitionExecutionResult::InternalError(_)], + "{:?}", + failed.execution_results() + ); + assert_eq!( + asset_lock_info(&platform, ®istration, platform_version), + StoredAssetLockInfo::NotPresent + ); + } + /// Before protocol version 14 these faults are charged to the asset lock and the /// transition's signable bytes are recorded as used, as advanced structure v0 does; /// those protocol versions keep this so their history replays unchanged. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/tests.rs index 104123a484c..98f16f265f4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/tests.rs @@ -11822,6 +11822,8 @@ mod tests { use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; use dpp::fee::fee_result::FeeResult; + use dpp::identity::accessors::IdentitySettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::prelude::UserFeeIncrease; use dpp::state_transition::public_key_in_creation::accessors::{ IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV0Setters, @@ -12262,6 +12264,169 @@ mod tests { } } + /// A transition spending `INPUT_AMOUNT` from `address` that registers, besides a master + /// and a critical key, a hash key whose data has `data_len` bytes. + async fn transition_with_a_hash_key_of_size( + address_signer: &TestAddressSigner, + address: PlatformAddress, + data_len: usize, + rng: &mut StdRng, + platform_version: &PlatformVersion, + ) -> StateTransition { + let (mut identity, identity_signer) = + create_identity_with_keys([93u8; 32], rng, platform_version); + identity.add_public_key( + IdentityPublicKeyV0 { + id: 2, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: BinaryData::new(vec![0x5a; data_len]), + disabled_at: None, + } + .into(), + ); + let mut inputs = BTreeMap::new(); + inputs.insert(address, (1 as AddressNonce, INPUT_AMOUNT)); + IdentityCreateFromAddressesTransition::try_from_inputs_with_signer( + &identity, + inputs, + None, + AddressFundsFeeStrategy::from(vec![AddressFundsFeeStrategyStep::DeductFromInput( + 0, + )]), + &identity_signer, + address_signer, + 0, + platform_version, + ) + .await + .expect("should create transition") + } + + /// A hash key's data is the hash itself, so from protocol version 14 data that is not 20 + /// bytes fails the key structure check: check_tx refuses it with a consensus error, and + /// a block charges the inputs like any other key structure failure, as the address + /// witnesses sign the keys. + #[tokio::test] + async fn should_charge_the_inputs_for_a_hash_key_of_the_wrong_size() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(platform_version); + let mut rng = StdRng::seed_from_u64(9700); + let mut address_signer = TestAddressSigner::new(); + let address = address_signer.add_p2pkh([99u8; 32]); + setup_address_with_balance_and_system_credits( + &mut platform, + address, + 0, + INITIAL_BALANCE, + ); + + let transition = transition_with_a_hash_key_of_size( + &address_signer, + address, + 19, + &mut rng, + platform_version, + ) + .await; + + let raw_transition = transition.serialize_to_bytes().expect("should serialize"); + let check_tx_result = run_check_tx(&platform, &raw_transition, platform_version); + assert_matches!( + check_tx_result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidIdentityPublicKeyDataError(error) + )] if error.public_key_id() == 2 + ); + + let (error, fees) = process_failing_transition(&platform, transition); + assert_matches!( + error, + ConsensusError::BasicError(BasicError::InvalidIdentityPublicKeyDataError(_)) + ); + assert_input_paid_the_fee_and_penalty( + &platform, + address, + INITIAL_BALANCE, + &fees, + platform_version + .drive_abci + .validation_and_processing + .penalties + .validation_of_added_keys_structure_failure, + ); + } + + /// Protocol version 13 does not check the size of a hash key's data, so such a key fails + /// once its hash is taken, as an internal error that keeps the transition out of every + /// block; that history replays unchanged. + #[tokio::test] + async fn should_fail_a_hash_key_of_the_wrong_size_internally_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let mut platform = setup_platform(platform_version); + let mut rng = StdRng::seed_from_u64(9800); + let mut address_signer = TestAddressSigner::new(); + let address = address_signer.add_p2pkh([100u8; 32]); + setup_address_with_balance_and_system_credits( + &mut platform, + address, + 0, + INITIAL_BALANCE, + ); + + let transition = transition_with_a_hash_key_of_size( + &address_signer, + address, + 19, + &mut rng, + platform_version, + ) + .await; + + assert_matches!( + process_block(&platform, transition).as_slice(), + [StateTransitionExecutionResult::InternalError(_)] + ); + } + + /// The same transition with 20 bytes of hash key data creates the identity, so the + /// failures above come from the key data alone. + #[tokio::test] + async fn should_create_an_identity_with_a_hash_key_of_20_bytes_at_protocol_versions_13_and_14( + ) { + for platform_version in platform_versions() { + let mut platform = setup_platform(platform_version); + let mut rng = StdRng::seed_from_u64(9900); + let mut address_signer = TestAddressSigner::new(); + let address = address_signer.add_p2pkh([101u8; 32]); + setup_address_with_balance_and_system_credits( + &mut platform, + address, + 0, + INITIAL_BALANCE, + ); + + let transition = transition_with_a_hash_key_of_size( + &address_signer, + address, + 20, + &mut rng, + platform_version, + ) + .await; + + assert_matches!( + process_block(&platform, transition).as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "at protocol version {}", + platform_version.protocol_version + ); + } + } + /// From protocol version 14 a key whose proof of possession fails is refused unpaid: the /// address witnesses do not sign the key signatures, so the inputs are not charged. #[tokio::test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index 92e876c80e4..19af7bb4579 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -1254,3 +1254,63 @@ fn should_refuse_a_key_with_limits_before_verifying_the_proof() { preimage(version_0_key.into()) ); } + +/// A hash key's data is the hash itself, so from protocol version 14 `validate_shielded_proof` +/// refuses a hash key whose data is not 20 bytes, before it verifies proofs of possession or the +/// bundle. Without the check, a transition with a valid proof would fail once its key hash is +/// taken, as an internal error. +#[test] +fn should_refuse_a_hash_key_of_the_wrong_size_before_verifying_the_proof() { + use crate::execution::validation::state_transition::processor::traits::shielded_proof::StateTransitionShieldedProofValidationV0; + use dpp::consensus::codes::ErrorWithCode; + use dpp::state_transition::StateTransition; + + const INVALID_IDENTITY_PUBLIC_KEY_DATA: u32 = 10511; + + let errors_for = |data_len: usize, version: &PlatformVersion| { + let hash_key = IdentityPublicKeyInCreationV0 { + id: 1, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + read_only: false, + data: vec![0x72; data_len].into(), + signature: Default::default(), + }; + let st: StateTransition = + transition(vec![master_key(), hash_key.into()], vec![action(31)]).into(); + st.validate_shielded_proof(version) + .expect("a refusal is a consensus error, not an internal one") + .errors + }; + + for data_len in [0, 19, 21] { + let errors = errors_for(data_len, PlatformVersion::latest()); + assert_eq!(errors.len(), 1, "{data_len} bytes: {errors:?}"); + assert_eq!( + errors[0].code(), + INVALID_IDENTITY_PUBLIC_KEY_DATA, + "{data_len} bytes" + ); + } + + // A 20 byte hash key gets past the check, and so does a key of the wrong size at protocol + // version 13, which does not check the size of a hash key's data: the transition fails + // later, on the placeholder proof of possession of its master key. + for (data_len, version) in [ + (20, PlatformVersion::latest()), + ( + 19, + PlatformVersion::get(13).expect("expected protocol version 13"), + ), + ] { + let errors = errors_for(data_len, version); + assert_matches!( + errors.as_slice(), + [ConsensusError::SignatureError(_)], + "{data_len} bytes at protocol version {}", + version.protocol_version + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs index 25d5ca0b898..f554a8af470 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs @@ -3549,4 +3549,224 @@ mod tests { ); } } + + mod hash_key_data { + use super::*; + use crate::execution::check_tx::CheckTxLevel; + use crate::platform_types::platform::PlatformRef; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::basic::BasicError; + use dpp::identity::{Identity, IdentityPublicKey, IdentityV0}; + use simple_signer::signer::SimpleSigner; + use std::collections::BTreeMap; + + const HASH_KEY_ID: u32 = 1; + + /// A platform at `platform_version` holding an identity with only a master key, and an + /// identity update, signed by that key, adding a hash key of `key_type` whose data has + /// `data_len` bytes. Hash keys carry no proof of possession, so the key data is all that + /// can be wrong with it. + async fn identity_update_adding_a_hash_key( + key_type: KeyType, + data_len: usize, + platform_version: &PlatformVersion, + ) -> (TempPlatform, StateTransition) { + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(platform_version.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + let mut rng = StdRng::seed_from_u64(417); + let (master_key, master_private_key) = + IdentityPublicKey::random_ecdsa_master_authentication_key_with_rng( + 0, + &mut rng, + platform_version, + ) + .expect("expected to get key pair"); + let mut signer = SimpleSigner::default(); + signer.add_identity_public_key(master_key.clone(), master_private_key); + let identity: Identity = IdentityV0 { + id: Identifier::random_with_rng(&mut rng), + public_keys: BTreeMap::from([(master_key.id(), master_key.clone())]), + balance: dash_to_credits!(0.1), + revision: 0, + } + .into(); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add a new identity"); + + let update_transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreationV0 { + id: HASH_KEY_ID, + key_type, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + read_only: false, + data: vec![0x5a; data_len].into(), + signature: Default::default(), + } + .into()], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: master_key.id(), + signature: Default::default(), + } + .into(); + let mut transition: StateTransition = update_transition.into(); + let signable_bytes = transition + .signable_bytes() + .expect("expected signable bytes"); + transition.set_signature( + signer + .sign(&master_key, signable_bytes.as_slice()) + .await + .expect("expected to sign"), + ); + (platform, transition) + } + + fn check_tx( + platform: &TempPlatform, + transition: &StateTransition, + platform_version: &PlatformVersion, + ) -> Vec { + let platform_state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + platform + .check_tx( + &transition + .serialize_to_bytes() + .expect("expected to serialize"), + CheckTxLevel::FirstTimeCheck, + &platform_ref, + platform_version, + ) + .expect("expected to check tx") + .errors + } + + fn process_block( + platform: &TempPlatform, + transition: &StateTransition, + platform_version: &PlatformVersion, + ) -> StateTransitionExecutionResult { + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[transition + .serialize_to_bytes() + .expect("expected to serialize")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + processing_result.execution_results()[0].clone() + } + + /// A hash key's data is the hash itself, so from protocol version 14 an identity update + /// adding one whose data is not 20 bytes fails basic structure validation: check_tx + /// refuses it with a consensus error, and a block refuses it unpaid, like any other + /// basic structure failure. + #[tokio::test] + async fn should_refuse_unpaid_an_identity_update_adding_a_hash_key_of_the_wrong_size() { + let platform_version = PlatformVersion::latest(); + for (key_type, data_len) in [ + (KeyType::ECDSA_HASH160, 0), + (KeyType::ECDSA_HASH160, 19), + (KeyType::ECDSA_HASH160, 21), + (KeyType::BIP13_SCRIPT_HASH, 19), + (KeyType::EDDSA_25519_HASH160, 19), + ] { + let (platform, transition) = + identity_update_adding_a_hash_key(key_type, data_len, platform_version).await; + + let errors = check_tx(&platform, &transition, platform_version); + assert_matches!( + errors.as_slice(), + [ConsensusError::BasicError( + BasicError::InvalidIdentityPublicKeyDataError(error) + )] if error.public_key_id() == HASH_KEY_ID, + "{key_type} with {data_len} bytes: {errors:?}" + ); + + let execution = process_block(&platform, &transition, platform_version); + assert_matches!( + execution, + StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::InvalidIdentityPublicKeyDataError( + _ + )) + ), + "{key_type} with {data_len} bytes" + ); + } + } + + /// Protocol version 13 does not check the size of a hash key's data, so such a key fails + /// once its hash is taken, as an internal error that keeps the transition out of every + /// block; that history replays unchanged. + #[tokio::test] + async fn should_fail_an_identity_update_adding_a_hash_key_of_the_wrong_size_internally_at_protocol_version_13( + ) { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let (platform, transition) = + identity_update_adding_a_hash_key(KeyType::ECDSA_HASH160, 19, platform_version) + .await; + assert_matches!( + process_block(&platform, &transition, platform_version), + StateTransitionExecutionResult::InternalError(_) + ); + } + + /// The same identity update with 20 bytes of hash key data adds the key, so the refusals + /// above come from the key data alone. + #[tokio::test] + async fn should_add_a_hash_key_of_20_bytes_at_protocol_versions_13_and_14() { + for platform_version in [ + PlatformVersion::get(13).expect("expected protocol version 13"), + PlatformVersion::latest(), + ] { + let (platform, transition) = + identity_update_adding_a_hash_key(KeyType::ECDSA_HASH160, 20, platform_version) + .await; + assert_matches!( + process_block(&platform, &transition, platform_version), + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "at protocol version {}", + platform_version.protocol_version + ); + } + } + } } diff --git a/packages/rs-drive-abci/src/main.rs b/packages/rs-drive-abci/src/main.rs index b5d964684d5..e21a81244eb 100644 --- a/packages/rs-drive-abci/src/main.rs +++ b/packages/rs-drive-abci/src/main.rs @@ -454,6 +454,9 @@ mod snapshot_bake_main { fn get_credit_pool_balance(&self, _: u32) -> Result { unreachable!() } + fn get_coinbase_payload_version(&self, _: u32) -> Result, Error> { + unreachable!() + } } /// Produce a shielded-pool snapshot at `out_path` from a fresh temporary diff --git a/packages/rs-drive-abci/src/rpc/core.rs b/packages/rs-drive-abci/src/rpc/core.rs index 7512e075555..e16e4e79863 100644 --- a/packages/rs-drive-abci/src/rpc/core.rs +++ b/packages/rs-drive-abci/src/rpc/core.rs @@ -58,6 +58,29 @@ pub(crate) fn credit_pool_balance_from_coinbase(coinbase: &[u8]) -> Result Result, String> { + let (transaction, _) = deserialize_partial::(coinbase) + .map_err(|e| format!("coinbase cannot be decoded: {e}"))?; + match transaction.special_transaction_payload { + None => Ok(None), + Some(TransactionPayload::CoinbasePayloadType(payload)) => Ok(Some(payload.version)), + Some(TransactionPayload::RawPayloadType(COINBASE_TRANSACTION_TYPE, bytes)) => { + let (payload, _) = deserialize_partial::(&bytes) + .map_err(|e| format!("coinbase payload cannot be decoded: {e}"))?; + Ok(Some(payload.version)) + } + Some(payload) => Err(format!( + "coinbase carries a {:?} payload", + payload.get_type() + )), + } +} + /// Core height must be of type u32 (Platform heights are u64) pub type CoreHeight = u32; /// Core RPC interface @@ -174,6 +197,11 @@ pub trait CoreRPCLike { /// block's coinbase (only the coinbase is transferred). Only ask for a chain locked height: /// the answer is then the same on every node. fn get_credit_pool_balance(&self, height: CoreHeight) -> Result; + + /// Get the version of the coinbase payload of the block at `height`, `None` for a coinbase + /// without a payload (only the coinbase is transferred). Only ask for a chain locked height: + /// the answer is then the same on every node. + fn get_coinbase_payload_version(&self, height: CoreHeight) -> Result, Error>; } #[derive(Debug)] @@ -290,6 +318,27 @@ impl DefaultCoreRPC { prefetcher, }) } + + /// The serialized coinbase of the block at `height`, `None` for a coinbase without a payload. + fn coinbase_with_payload(&self, height: CoreHeight) -> Result>, Error> { + let block_hash = self.get_block_hash(height)?; + // Only the coinbase, as raw hex: the special transactions of type 5, the first one, + // verbosity 1. An empty answer is a coinbase without a payload, before DIP3. + let args = [ + Value::String(block_hash.to_string()), + Value::from(COINBASE_TRANSACTION_TYPE), + Value::from(1), + Value::from(0), + Value::from(1), + ]; + let coinbases = retry!(self.inner.call::>("getspecialtxes", &args))?; + let Some(coinbase) = coinbases.first() else { + return Ok(None); + }; + hex::decode(coinbase).map(Some).map_err(|e| { + Error::UnexpectedStructure(format!("getspecialtxes answered invalid hex: {e}")) + }) + } } impl CoreRPCLike for DefaultCoreRPC { @@ -482,30 +531,23 @@ impl CoreRPCLike for DefaultCoreRPC { } fn get_credit_pool_balance(&self, height: CoreHeight) -> Result { - let block_hash = self.get_block_hash(height)?; - // Only the coinbase, as raw hex: the special transactions of type 5, the first one, - // verbosity 1. An empty answer is a coinbase without a payload, before DIP3. - let args = [ - Value::String(block_hash.to_string()), - Value::from(COINBASE_TRANSACTION_TYPE), - Value::from(1), - Value::from(0), - Value::from(1), - ]; - let coinbases = retry!(self.inner.call::>("getspecialtxes", &args))?; - let Some(coinbase) = coinbases.first() else { + let Some(coinbase) = self.coinbase_with_payload(height)? else { return Ok(0); }; - let coinbase = hex::decode(coinbase).map_err(|e| { - Error::UnexpectedStructure(format!("getspecialtxes answered invalid hex: {e}")) - })?; credit_pool_balance_from_coinbase(&coinbase).map_err(Error::UnexpectedStructure) } + + fn get_coinbase_payload_version(&self, height: CoreHeight) -> Result, Error> { + let Some(coinbase) = self.coinbase_with_payload(height)? else { + return Ok(None); + }; + coinbase_payload_version_from_coinbase(&coinbase).map_err(Error::UnexpectedStructure) + } } #[cfg(test)] mod tests { - use super::credit_pool_balance_from_coinbase; + use super::{coinbase_payload_version_from_coinbase, credit_pool_balance_from_coinbase}; use dpp::dashcore::bls_sig_utils::BLSSignature; use dpp::dashcore::consensus::serialize; use dpp::dashcore::hash_types::{MerkleRootMasternodeList, MerkleRootQuorums}; @@ -633,6 +675,32 @@ mod tests { assert!(credit_pool_balance_from_coinbase(&full[..40]).is_err()); } + /// The payload version tells whether Core's rules for that block required the version 4 + /// payload, which Core v24 does exactly at the blocks where DEPLOYMENT_V24 and V20 are active. + #[test] + fn should_read_the_payload_version_of_a_coinbase() { + assert_eq!( + coinbase_payload_version_from_coinbase(&coinbase(Some(version_3_payload()))), + Ok(Some(3)) + ); + assert_eq!( + coinbase_payload_version_from_coinbase(&raised_coinbase(4, &[0xaa; 32])), + Ok(Some(4)) + ); + let mut after_balance = vec![0xaa; 32]; + after_balance.extend_from_slice(&[0xbb; 16]); + assert_eq!( + coinbase_payload_version_from_coinbase(&raised_coinbase(5, &after_balance)), + Ok(Some(5)) + ); + assert_eq!( + coinbase_payload_version_from_coinbase(&coinbase(None)), + Ok(None) + ); + let full = coinbase(Some(version_3_payload())); + assert!(coinbase_payload_version_from_coinbase(&full[..40]).is_err()); + } + /// Reading a Core deployment over a real JSON-RPC connection, from a mock Core that serves /// answers recorded from Dash Core v24.0.0-rc.3 and v23.1.8. mod fork_info { diff --git a/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/1-399.json b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/1-399.json new file mode 100644 index 00000000000..4107e00295d --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/1-399.json @@ -0,0 +1,122 @@ +{ + "baseHeight": 1, + "blockHeight": 399, + "addedMNs": [ + { + "type": "Regular", + "proTxHash": "192a79c8bcd46a6f8500b3010d6f4145a6580a0a0294500dfbb96dbd4c2dd625", + "collateralHash": "192a79c8bcd46a6f8500b3010d6f4145a6580a0a0294500dfbb96dbd4c2dd625", + "collateralIndex": 1, + "collateralAddress": "yPhRDymQcNvoBAzTeX8qA6j9fjcJcvRroJ", + "operatorReward": 10, + "state": { + "version": 1, + "service": "127.0.0.1:30003", + "addresses": { + "core_p2p": [ + "127.0.0.1:30003" + ] + }, + "registeredHeight": 302, + "lastPaidHeight": 399, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "ownerAddress": "yMNTatYGj3fGtP71M8yq3wKosgNpWWwY3o", + "votingAddress": "yeCszg8tiRumEiqn4giKjxnrNRcQe34bR1", + "payoutAddress": "ybFJK42oGazedqiffcBRavvuzyt9ZeHGyR", + "pubKeyOperator": "8e03b290379bfc0b6632e4eff4f45f40ff6888b49f0bfbca53f6e4f452c56b56a7c4f133dab94b0f61c05f3077317a9c", + "operatorPayoutAddress": "yczTrXvLFdFt6wr3boYVrbHZD7wPhKpUzF" + } + }, + { + "type": "Regular", + "proTxHash": "802390501809bee03accfa3742d62018a550324a918b85c6d106a51d946e8f2f", + "collateralHash": "802390501809bee03accfa3742d62018a550324a918b85c6d106a51d946e8f2f", + "collateralIndex": 1, + "collateralAddress": "ybMhiYCGBJds49UH8hFSen4yRzQ9ZnigKo", + "operatorReward": 0, + "state": { + "version": 1, + "service": "127.0.0.1:30004", + "addresses": { + "core_p2p": [ + "127.0.0.1:30004" + ] + }, + "registeredHeight": 302, + "lastPaidHeight": 396, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "ownerAddress": "yTmAqToskBkRCKJvMrF4E5aSmQcrm44rcw", + "votingAddress": "yYhFvbh7gZzbFqjcWLCCbtjV6gcTuxCnQY", + "payoutAddress": "yNz4SEKxjetayb6PYEoBJuTpKVkLuBdAWM", + "pubKeyOperator": "0c718ddf5a8e115c2a4ef8bf906e89a69cd54b29405010ca16047581d92f382835ca6bb558a9ee237ed2879e312a3eb9" + } + }, + { + "type": "Regular", + "proTxHash": "f18c25367cd4f2ba6e8cdf86d0ffcf2694b6206b75597fe1ab5b80b966dbdf4b", + "collateralHash": "f18c25367cd4f2ba6e8cdf86d0ffcf2694b6206b75597fe1ab5b80b966dbdf4b", + "collateralIndex": 1, + "collateralAddress": "yXaoemYGMrsSx7X79mnR2r866Qh3RmaQZz", + "operatorReward": 0, + "state": { + "version": 1, + "service": "127.0.0.1:30001", + "addresses": { + "core_p2p": [ + "127.0.0.1:30001" + ] + }, + "registeredHeight": 302, + "lastPaidHeight": 397, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "ownerAddress": "yMKAVzwrrWp8qpHrARkS9ws7eJ6L14bx7K", + "votingAddress": "yMc9vb33cZuhkNVfFBQu38A9KmtqQD41vc", + "payoutAddress": "yXkZa7p2ne58x2XQYdNthAxfXy9cHiWxF5", + "pubKeyOperator": "06ebb5ebd9079d73577e0900c9c10139d7d7213247e32b9b8823cd80e6a18e16a497ac57508e23b4a0e0510bfc880ad9" + } + }, + { + "type": "Regular", + "proTxHash": "02cc32acbd70020b192abdddfd8ab6d682b0e75c6cbe225f60fe6cf9da08859b", + "collateralHash": "02cc32acbd70020b192abdddfd8ab6d682b0e75c6cbe225f60fe6cf9da08859b", + "collateralIndex": 1, + "collateralAddress": "yf3yXRAkAHD53n4hP8KCR8JSNbomf4D6H2", + "operatorReward": 10, + "state": { + "version": 1, + "service": "127.0.0.1:30002", + "addresses": { + "core_p2p": [ + "127.0.0.1:30002" + ] + }, + "registeredHeight": 302, + "lastPaidHeight": 398, + "consecutivePayments": 0, + "PoSePenalty": 0, + "PoSeRevivedHeight": -1, + "PoSeBanHeight": -1, + "revocationReason": 0, + "ownerAddress": "yhKZ8Zy1243u88wFujwkScuW9Lkomo2NHZ", + "votingAddress": "yhUTNhZjweRvHEJ43JUefpTuHavmDh3LjP", + "payoutAddress": "yXCxDa7nTBA31La9SVxh2Dr5k9BrF6JBcJ", + "pubKeyOperator": "910633a672392e26860cbff612b28d84f71beb34c2092d015b0d522905d628d38d746c1088c654632c65c6a7f008095d", + "operatorPayoutAddress": "yMY3XstUm4p29eX3GLuRggyy9o8tQhp4HP" + } + } + ], + "removedMNs": [], + "updatedMNs": [] +} diff --git a/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/399-400.json b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/399-400.json new file mode 100644 index 00000000000..a1d9e81792f --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/399-400.json @@ -0,0 +1,13 @@ +{ + "baseHeight": 399, + "blockHeight": 400, + "addedMNs": [], + "removedMNs": [], + "updatedMNs": [ + { + "802390501809bee03accfa3742d62018a550324a918b85c6d106a51d946e8f2f": { + "lastPaidHeight": 400 + } + } + ] +} diff --git a/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/399-404.json b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/399-404.json new file mode 100644 index 00000000000..924512f628c --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/399-404.json @@ -0,0 +1,81 @@ +{ + "baseHeight": 399, + "blockHeight": 404, + "addedMNs": [], + "removedMNs": [], + "updatedMNs": [ + { + "02cc32acbd70020b192abdddfd8ab6d682b0e75c6cbe225f60fe6cf9da08859b": { + "version": 3, + "service": "127.0.0.1:30002", + "lastPaidHeight": 402, + "votingAddress": "yd59MzNMSWf5LdNE9qxZGrcFeR2BzjuqM2", + "payouts": [ + { + "address": "yXCxDa7nTBA31La9SVxh2Dr5k9BrF6JBcJ", + "script": "76a914776efcc75b86258a0e57fe0ae4ac9220dcd43f1988ac", + "reward": 10000 + } + ], + "pubKeyOperator": "b10633a672392e26860cbff612b28d84f71beb34c2092d015b0d522905d628d38d746c1088c654632c65c6a7f008095d", + "addresses": { + "core_p2p": [ + "127.0.0.1:30002" + ] + } + } + }, + { + "802390501809bee03accfa3742d62018a550324a918b85c6d106a51d946e8f2f": { + "version": 3, + "service": "[::]:0", + "lastPaidHeight": 404, + "PoSeBanHeight": 404, + "payouts": [ + { + "address": "yNz4SEKxjetayb6PYEoBJuTpKVkLuBdAWM", + "script": "76a9141d3dc3ae7f74bea67970601790b9764ff777d53188ac", + "reward": 10000 + } + ], + "pubKeyOperator": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + }, + { + "f18c25367cd4f2ba6e8cdf86d0ffcf2694b6206b75597fe1ab5b80b966dbdf4b": { + "version": 3, + "service": "127.0.0.1:30001", + "lastPaidHeight": 401, + "payouts": [ + { + "address": "yXkZa7p2ne58x2XQYdNthAxfXy9cHiWxF5", + "script": "76a9147d6964fc3445684608bd81ab3772baebae46735d88ac", + "reward": 10000 + } + ], + "pubKeyOperator": "86ebb5ebd9079d73577e0900c9c10139d7d7213247e32b9b8823cd80e6a18e16a497ac57508e23b4a0e0510bfc880ad9", + "addresses": { + "core_p2p": [ + "127.0.0.1:30001" + ] + } + } + }, + { + "192a79c8bcd46a6f8500b3010d6f4145a6580a0a0294500dfbb96dbd4c2dd625": { + "version": 3, + "service": "[::]:0", + "lastPaidHeight": 403, + "PoSeBanHeight": 403, + "payouts": [ + { + "address": "ybFJK42oGazedqiffcBRavvuzyt9ZeHGyR", + "script": "76a914a3c118b5c366b55179728c64178fd372d8cf6d4788ac", + "reward": 10000 + } + ], + "pubKeyOperator": "ac4121f7c123b5e265d59053f5988a4d2677a794f2ab8b9147b88436b47c690fdd44af7868ca9047199c346c9323b3bf" + } + } + ] +} diff --git a/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/400-401.json b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/400-401.json new file mode 100644 index 00000000000..118f0442449 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/400-401.json @@ -0,0 +1,28 @@ +{ + "baseHeight": 400, + "blockHeight": 401, + "addedMNs": [], + "removedMNs": [], + "updatedMNs": [ + { + "f18c25367cd4f2ba6e8cdf86d0ffcf2694b6206b75597fe1ab5b80b966dbdf4b": { + "version": 3, + "service": "127.0.0.1:30001", + "lastPaidHeight": 401, + "payouts": [ + { + "address": "yXkZa7p2ne58x2XQYdNthAxfXy9cHiWxF5", + "script": "76a9147d6964fc3445684608bd81ab3772baebae46735d88ac", + "reward": 10000 + } + ], + "pubKeyOperator": "86ebb5ebd9079d73577e0900c9c10139d7d7213247e32b9b8823cd80e6a18e16a497ac57508e23b4a0e0510bfc880ad9", + "addresses": { + "core_p2p": [ + "127.0.0.1:30001" + ] + } + } + } + ] +} diff --git a/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/401-402.json b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/401-402.json new file mode 100644 index 00000000000..04724c68514 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/401-402.json @@ -0,0 +1,29 @@ +{ + "baseHeight": 401, + "blockHeight": 402, + "addedMNs": [], + "removedMNs": [], + "updatedMNs": [ + { + "02cc32acbd70020b192abdddfd8ab6d682b0e75c6cbe225f60fe6cf9da08859b": { + "version": 3, + "service": "127.0.0.1:30002", + "lastPaidHeight": 402, + "votingAddress": "yd59MzNMSWf5LdNE9qxZGrcFeR2BzjuqM2", + "payouts": [ + { + "address": "yXCxDa7nTBA31La9SVxh2Dr5k9BrF6JBcJ", + "script": "76a914776efcc75b86258a0e57fe0ae4ac9220dcd43f1988ac", + "reward": 10000 + } + ], + "pubKeyOperator": "b10633a672392e26860cbff612b28d84f71beb34c2092d015b0d522905d628d38d746c1088c654632c65c6a7f008095d", + "addresses": { + "core_p2p": [ + "127.0.0.1:30002" + ] + } + } + } + ] +} diff --git a/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/402-403.json b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/402-403.json new file mode 100644 index 00000000000..3f900c4c04c --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/402-403.json @@ -0,0 +1,24 @@ +{ + "baseHeight": 402, + "blockHeight": 403, + "addedMNs": [], + "removedMNs": [], + "updatedMNs": [ + { + "192a79c8bcd46a6f8500b3010d6f4145a6580a0a0294500dfbb96dbd4c2dd625": { + "version": 3, + "service": "[::]:0", + "lastPaidHeight": 403, + "PoSeBanHeight": 403, + "payouts": [ + { + "address": "ybFJK42oGazedqiffcBRavvuzyt9ZeHGyR", + "script": "76a914a3c118b5c366b55179728c64178fd372d8cf6d4788ac", + "reward": 10000 + } + ], + "pubKeyOperator": "ac4121f7c123b5e265d59053f5988a4d2677a794f2ab8b9147b88436b47c690fdd44af7868ca9047199c346c9323b3bf" + } + } + ] +} diff --git a/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/403-404.json b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/403-404.json new file mode 100644 index 00000000000..dc77e1a24d2 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/core_v24_protx_list_diffs/legacy_operator_key/403-404.json @@ -0,0 +1,24 @@ +{ + "baseHeight": 403, + "blockHeight": 404, + "addedMNs": [], + "removedMNs": [], + "updatedMNs": [ + { + "802390501809bee03accfa3742d62018a550324a918b85c6d106a51d946e8f2f": { + "version": 3, + "service": "[::]:0", + "lastPaidHeight": 404, + "PoSeBanHeight": 404, + "payouts": [ + { + "address": "yNz4SEKxjetayb6PYEoBJuTpKVkLuBdAWM", + "script": "76a9141d3dc3ae7f74bea67970601790b9764ff777d53188ac", + "reward": 10000 + } + ], + "pubKeyOperator": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + } + ] +} diff --git a/packages/rs-drive/src/drive/identity/update/methods/add_new_keys_to_identity/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/add_new_keys_to_identity/mod.rs index 448bb8e67dd..0067b43c078 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/add_new_keys_to_identity/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/add_new_keys_to_identity/mod.rs @@ -66,4 +66,58 @@ impl Drive { })), } } + + /// The operations for adding new non-unique keys to a masternode identity. This function is + /// version controlled by the version of `add_new_keys_to_identity_operations`, whose + /// operations it emits when estimating costs and for every key whose hash is not a unique + /// key hash. + /// + /// A key whose hash is already a unique key hash is added without a non-unique key hash + /// reference, matching how keys are stored when a masternode identity is created. + /// + /// # Arguments + /// + /// * `identity_id` - The ID of the masternode identity to which keys are to be added. + /// * `non_unique_keys_to_add` - The keys to be added, all of non-unique key types. + /// * `epoch` - The current epoch. + /// * `estimated_costs_only_with_layer_info` - The estimated costs with layer information. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version. + /// + /// # Returns + /// + /// * `Result, Error>` - The resulting low level drive operations if successful, or an error. + pub fn add_new_keys_to_masternode_identity_operations( + &self, + identity_id: [u8; 32], + non_unique_keys_to_add: Vec, + epoch: &Epoch, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .identity + .update + .add_new_keys_to_identity + { + 0 => self.add_new_keys_to_masternode_identity_operations_v0( + identity_id, + non_unique_keys_to_add, + epoch, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_new_keys_to_masternode_identity_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } } diff --git a/packages/rs-drive/src/drive/identity/update/methods/add_new_keys_to_identity/v0/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/add_new_keys_to_identity/v0/mod.rs index ceb399c4d13..efb54fd9e39 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/add_new_keys_to_identity/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/add_new_keys_to_identity/v0/mod.rs @@ -1,6 +1,7 @@ use crate::drive::Drive; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use dpp::identity::identity_public_key::methods::hash::IdentityPublicKeyHashMethodsV0; use dpp::identity::IdentityPublicKey; use dpp::block::epoch::Epoch; @@ -64,4 +65,53 @@ impl Drive { } Ok(drive_operations) } + + /// The operations for adding new non-unique keys to a masternode identity + #[inline(always)] + pub(super) fn add_new_keys_to_masternode_identity_operations_v0( + &self, + identity_id: [u8; 32], + non_unique_keys_to_add: Vec, + epoch: &Epoch, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations: Vec = vec![]; + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_keys_for_identity_id( + identity_id, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + for key in non_unique_keys_to_add { + // When estimating costs the reference is assumed, as by `AddNewKeysToIdentity`. + // Otherwise a key whose hash is already a unique key hash is added without a + // non-unique key hash reference. The probe records no cost operation, so every + // other key gets exactly the operations of `AddNewKeysToIdentity`. + let with_reference_to_non_unique_key = estimated_costs_only_with_layer_info.is_some() + || !self.has_unique_public_key_hash( + key.public_key_hash()?, + transaction, + &platform_version.drive, + )?; + + self.insert_new_non_unique_key_operations( + identity_id, + key, + with_reference_to_non_unique_key, + true, + epoch, + estimated_costs_only_with_layer_info, + transaction, + &mut drive_operations, + platform_version, + )?; + } + Ok(drive_operations) + } } diff --git a/packages/rs-drive/src/drive/identity/update/mod.rs b/packages/rs-drive/src/drive/identity/update/mod.rs index fb53ee88325..77397eb95b3 100644 --- a/packages/rs-drive/src/drive/identity/update/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/mod.rs @@ -397,6 +397,101 @@ mod tests { } } + mod add_new_keys_to_masternode_identity { + use super::*; + use crate::util::batch::drive_op_batch::DriveLowLevelOperationConverter; + use crate::util::batch::IdentityOperationType; + use dpp::block::block_info::BlockInfo; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityV0, KeyID, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::version::PlatformVersion; + use std::collections::{BTreeMap, HashMap}; + + const MASTERNODE_IDENTITY_ID: [u8; 32] = [1; 32]; + + fn masternode_key(id: KeyID, key_type: KeyType, purpose: Purpose) -> IdentityPublicKey { + IdentityPublicKeyV0 { + id, + key_type, + purpose, + security_level: SecurityLevel::CRITICAL, + read_only: true, + data: BinaryData::new(vec![(id + 2) as u8; 20]), + disabled_at: None, + contract_bounds: None, + } + .into() + } + + #[test] + fn should_emit_the_operations_of_add_new_keys_to_identity_at_every_protocol_version() { + for protocol_version in 1..=PlatformVersion::latest().protocol_version { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected a platform version"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let masternode_identity: Identity = IdentityV0 { + id: MASTERNODE_IDENTITY_ID.into(), + public_keys: BTreeMap::from([( + 0, + masternode_key(0, KeyType::ECDSA_HASH160, Purpose::TRANSFER), + )]), + balance: 0, + revision: 0, + } + .into(); + drive + .add_new_identity( + masternode_identity, + true, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert the masternode identity"); + + let keys = vec![ + masternode_key(1, KeyType::ECDSA_HASH160, Purpose::TRANSFER), + masternode_key(2, KeyType::EDDSA_25519_HASH160, Purpose::SYSTEM), + ]; + let operations = |operation: IdentityOperationType, estimate: bool| { + let mut estimated_costs = estimate.then(HashMap::new); + let operations = operation + .into_low_level_drive_operations( + &drive, + &mut estimated_costs, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected operations"); + (operations, estimated_costs) + }; + for estimate in [false, true] { + assert_eq!( + operations( + IdentityOperationType::AddNewKeysToMasternodeIdentity { + identity_id: MASTERNODE_IDENTITY_ID, + non_unique_keys_to_add: keys.clone(), + }, + estimate + ), + operations( + IdentityOperationType::AddNewKeysToIdentity { + identity_id: MASTERNODE_IDENTITY_ID, + unique_keys_to_add: vec![], + non_unique_keys_to_add: keys.clone(), + }, + estimate + ), + "protocol version {protocol_version}, estimate {estimate}" + ); + } + } + } + } + mod disable_identity_keys { use super::*; use chrono::Utc; diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/identity.rs b/packages/rs-drive/src/util/batch/drive_op_batch/identity.rs index 378e1353944..eb8f1a73867 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/identity.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/identity.rs @@ -55,6 +55,15 @@ pub enum IdentityOperationType { /// The non unique keys to be added non_unique_keys_to_add: Vec, }, + /// Adds an array of non unique keys to a masternode identity + /// A key whose hash is already a unique key hash is added without a non unique key hash + /// reference, matching how keys are stored when a masternode identity is created + AddNewKeysToMasternodeIdentity { + /// The identity id of the masternode identity + identity_id: [u8; 32], + /// The non unique keys to be added + non_unique_keys_to_add: Vec, + }, /// Disable Identity Keys DisableIdentityKeys { /// The identity id of the identity @@ -180,6 +189,17 @@ impl DriveLowLevelOperationConverter for IdentityOperationType { transaction, platform_version, ), + IdentityOperationType::AddNewKeysToMasternodeIdentity { + identity_id, + non_unique_keys_to_add, + } => drive.add_new_keys_to_masternode_identity_operations( + identity_id, + non_unique_keys_to_add, + &block_info.epoch, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), IdentityOperationType::DisableIdentityKeys { identity_id, keys_ids, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs index 5f797f3d137..f00e3e9b46d 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs @@ -4,9 +4,9 @@ use crate::version::dpp_versions::dpp_state_transition_method_versions::{ /// V2 is protocol version 14's table. `validate_identity_public_keys_structure` 0 -> 1: a public /// key in creation may carry a budget or an expiry, which v1 only admits on AUTHENTICATION keys -/// below the MASTER security level and with a non-zero budget. `verify_identity_signed_signature` -/// 0 -> 1: a BLS12_381 signature must verify, where 0 accepted one that is well formed but does -/// not. V1 stays as is for replay. +/// below the MASTER security level and with a non-zero budget, and the data of a hash key must +/// be 20 bytes. `verify_identity_signed_signature` 0 -> 1: a BLS12_381 signature must verify, +/// where 0 accepted one that is well formed but does not. V1 stays as is for replay. pub const STATE_TRANSITION_METHOD_VERSIONS_V2: DPPStateTransitionMethodVersions = DPPStateTransitionMethodVersions { public_key_in_creation_methods: PublicKeyInCreationMethodVersions { diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 0f8e6716b93..e406f392364 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2682,6 +2682,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// typed array and a `where`, and every contract that registers today /// binds no typed array. /// +/// 96. **A hash key's data is 20 bytes**: key structure validation v1 +/// (`STATE_TRANSITION_METHOD_VERSIONS_V2`, in place) refuses an +/// ECDSA_HASH160, BIP13_SCRIPT_HASH or EDDSA_25519_HASH160 key whose data +/// is not 20 bytes (`InvalidIdentityPublicKeyDataError`, 10511), after its +/// other rules. Each transition keeps the outcome of its key structure +/// check: charged in identity create and identity create from addresses, +/// unpaid in identity update and identity create from the shielded pool. +/// Such a key otherwise fails when its hash is taken, as an internal error +/// that keeps the transition out of every block. The data of an +/// ECDSA_SECP256K1 or BLS12_381 key is left to its proof of possession, +/// which refuses data that is not a public key. Earlier versions select +/// generation 0, which does not check the size of a hash key's data. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's @@ -2777,7 +2790,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { validation: DPP_VALIDATION_VERSIONS_V5, // changed: validate_config_update 2 admits the contract moderation declaration of config V2 state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, // changed: the indexOnly delete-by-values kind (documentIndexOnlyDelete) joins the wire; ShieldFromAssetLock moves to version 1 alone; the ContractUserModeration transition state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, - state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, // changed: public keys in creation may carry a budget or an expiry; verify_identity_signed_signature 1: a BLS12_381 signature must verify + state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, // changed: public keys in creation may carry a budget or an expiry, and a hash key's data must be 20 bytes; verify_identity_signed_signature 1: a BLS12_381 signature must verify state_transitions: STATE_TRANSITION_VERSIONS_V4, contract_versions: CONTRACT_VERSIONS_V6, // changed: token_configuration_format max_version 1 admits the shielded pool opt-in; v3 document meta-schema hosts the ranked, refersTo, requiredSince and timeRange keywords; validate_structure_interval v1 rejects a zero epoch interval; config max_version 2 (the contract moderation declaration) and validate_moderation_config; validate_document 1 rejects repeated nested text keys; serialize_value_for_key 1 keys unsigned integers in value order (except a property ending a rangeSummable index); generation 3 indexes the elements of typed arrays, on indexOnly types under the item 92 rules document_versions: DOCUMENT_VERSIONS_V4, // changed: document serialization format 3 — the contract version stamp that enables `requiredSince` properties diff --git a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs index d0b79103821..416e82870dc 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs @@ -149,6 +149,23 @@ pub struct ContactRequestFFI { /// Number of `u32` entries in [`Self::accepted_accounts`]; `0` when the /// pointer is null. pub accepted_accounts_len: usize, + /// Whether [`Self::external_account_reference`] is set — the mirror of + /// `EstablishedContact::external_account_reference` being `Some`. + /// Relationship-level, replicated onto BOTH established rows like + /// [`Self::payment_channel_broken`]; always `false` on pending rows. + /// + /// Without this the marker died with the process: every cold start saw + /// `None`, treated it as a rotation, tore the outbound + /// `DashpayExternalAccount` down and rebuilt it — and the rebuild rewound + /// the filter-scan cursor to the earliest contact's request height on + /// every launch (dashpay/platform#4302). A host that leaves it `false` + /// keeps that rebuild (the cursor no longer moves for it, but the + /// account churn remains). + pub has_external_account_reference: bool, + /// The `incoming_request.account_reference` the registered outbound + /// account was built from; meaningful only when + /// [`Self::has_external_account_reference`]. + pub external_account_reference: u32, } /// Composite identifier for [`ContactChangeSet::removed_sent`] and @@ -228,9 +245,12 @@ pub struct ContactIgnoredSenderFFI { // 176..=183 contact_account_label *const c_char // 184..=191 accepted_accounts *const u32 // 192..=199 accepted_accounts_len usize +// 200 has_external_account_reference bool +// 201..=203 (padding to 4) +// 204..=207 external_account_reference u32 // -// Total size = 200, alignment = 8 (from u64 / pointer fields). -const _: [u8; 200] = [0u8; std::mem::size_of::()]; +// Total size = 208, alignment = 8 (from u64 / pointer fields). +const _: [u8; 208] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // Expected `ContactRequestRemovalFFI` layout: 64 bytes, alignment 1. @@ -297,6 +317,7 @@ impl ContactRequestFFI { false, None, &[], + None, ) } @@ -318,6 +339,7 @@ impl ContactRequestFFI { false, None, &[], + None, ) } @@ -339,6 +361,7 @@ impl ContactRequestFFI { note: Option<&str>, is_hidden: bool, accepted_accounts: &[u32], + external_account_reference: Option, ) -> Self { Self::from_parts( owner_id, @@ -353,6 +376,7 @@ impl ContactRequestFFI { // it is direction-specific (incoming-only). None, accepted_accounts, + external_account_reference, ) } @@ -371,6 +395,7 @@ impl ContactRequestFFI { is_hidden: bool, contact_account_label: Option<&str>, accepted_accounts: &[u32], + external_account_reference: Option, ) -> Self { Self::from_parts( owner_id, @@ -383,6 +408,7 @@ impl ContactRequestFFI { is_hidden, contact_account_label, accepted_accounts, + external_account_reference, ) } @@ -398,6 +424,7 @@ impl ContactRequestFFI { is_hidden: bool, contact_account_label: Option<&str>, accepted_accounts: &[u32], + external_account_reference: Option, ) -> Self { let (encrypted_public_key, encrypted_public_key_len) = allocate_byte_buffer(&request.encrypted_public_key); @@ -434,6 +461,8 @@ impl ContactRequestFFI { contact_account_label: allocate_c_string(contact_account_label), accepted_accounts, accepted_accounts_len, + has_external_account_reference: external_account_reference.is_some(), + external_account_reference: external_account_reference.unwrap_or(0), } } } @@ -692,6 +721,7 @@ mod tests { Some("a note"), true, &[], + None, ); let mut inc = ContactRequestFFI::from_established_incoming( owner, @@ -703,6 +733,7 @@ mod tests { true, None, &[], + None, ); assert!(out.is_outgoing); assert!(!inc.is_outgoing); @@ -726,6 +757,7 @@ mod tests { None, false, &[], + None, ); assert!(!healthy.payment_channel_broken); assert!(healthy.alias.is_null()); @@ -762,6 +794,7 @@ mod tests { None, false, &[], + None, ); let mut inc = ContactRequestFFI::from_established_incoming( owner, @@ -773,6 +806,7 @@ mod tests { false, Some("Main wallet"), &[], + None, ); assert!( @@ -796,6 +830,71 @@ mod tests { ); } + /// `EstablishedContact::external_account_reference` crosses as a + /// `(has, value)` pair on BOTH established rows (it is relationship-level, + /// like the broken-channel flag) and is absent on pending rows. Losing it + /// across a restart is what rebuilt every outbound account per launch + /// (dashpay/platform#4302). + #[test] + fn established_rows_carry_the_external_account_reference_pending_rows_do_not() { + let request = sample_request(); + let owner = [5u8; 32]; + let contact = [6u8; 32]; + + let mut out = ContactRequestFFI::from_established_outgoing( + owner, + contact, + &request, + false, + None, + None, + false, + &[], + Some(4), + ); + let mut inc = ContactRequestFFI::from_established_incoming( + owner, + contact, + &request, + false, + None, + None, + false, + None, + &[], + Some(4), + ); + for row in [&out, &inc] { + assert!(row.has_external_account_reference); + assert_eq!(row.external_account_reference, 4); + } + + let mut unstamped = ContactRequestFFI::from_established_incoming( + owner, + contact, + &request, + false, + None, + None, + false, + None, + &[], + None, + ); + assert!(!unstamped.has_external_account_reference); + assert_eq!(unstamped.external_account_reference, 0); + + let mut pending = ContactRequestFFI::from_incoming(owner, contact, &request); + assert!(!pending.has_external_account_reference); + + unsafe { + free_contact_requests_ffi(&mut out as *mut ContactRequestFFI, 1); + free_contact_requests_ffi(&mut inc as *mut ContactRequestFFI, 1); + free_contact_requests_ffi(&mut unstamped as *mut ContactRequestFFI, 1); + free_contact_requests_ffi(&mut pending as *mut ContactRequestFFI, 1); + } + } + /// `ContactIgnoredSenderFFI::new` must carry the `(owner, sender)` /// suppression key and the insert/remove `is_ignored` bit, so the /// Swift handler can persist an ignore (`true`) or delete an diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 811b4778894..dd56522df75 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -108,6 +108,50 @@ pub struct UtxoCreditVerdictFFI { pub spent_at_height: u32, } +/// One receival contact the DashPay coreHeight backfill covers, and the +/// height it covers it from — the C mirror of +/// [`DashPayBackfillCoveredContact`](platform_wallet::changeset::DashPayBackfillCoveredContact). +/// +/// Delivered through the persistence extension's +/// `on_persist_wallet_dashpay_backfill_fn` slot alongside the record's +/// `floor` / `rewound_from`, and handed back at restore on +/// `WalletRestoreEntryFFI::dashpay_backfill_covered`. Rides the extension +/// rather than `WalletChangeSetFFI` for the layout reason documented there: +/// that struct crosses by bare pointer and cannot prove a new field present, +/// while the extension's `struct_size` can. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DashPayBackfillCoveredContactFFI { + /// Our identity — the owner of the receival account. + pub owner_identity_id: [u8; 32], + /// The contact whose payments the receival account collects. + pub contact_identity_id: [u8; 32], + /// The receival account's index — coverage is per receival account, not + /// per relationship. + pub account_index: u32, + /// Lowest height the scan is guaranteed to test with this contact's + /// addresses watched. + pub covered_from: u32, +} + +/// Project a backfill record's cover set into its C mirrors for the +/// extension slot, in record order (sorted by `(owner, contact, +/// account_index)`). +pub fn build_dashpay_backfill_covered_for_callback( + record: &platform_wallet::changeset::DashPayBackfillRecord, +) -> Vec { + record + .covered() + .iter() + .map(|entry| DashPayBackfillCoveredContactFFI { + owner_identity_id: entry.owner.to_buffer(), + contact_identity_id: entry.contact.to_buffer(), + account_index: entry.account_index, + covered_from: entry.covered_from, + }) + .collect() +} + /// Project a changeset's credit verdicts into their C mirrors for the /// extension slot, in outpoint order (the map's own ordering — stable, /// so a host log of a round is reproducible). diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 731fb5c6a32..e1ccdaad053 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -11,8 +11,9 @@ use crate::persistence::{ FFIPersister, FreeTrackedMasternodesFn, LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, - PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, - PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, + PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; @@ -277,6 +278,10 @@ unsafe fn persistence_extension_callbacks( on_load_identity_balance_block_time_fn, LoadIdentityBalanceBlockTimeFn ), + wallet_dashpay_backfill: slot!( + on_persist_wallet_dashpay_backfill_fn, + PersistWalletDashPayBackfillFn + ), } } @@ -905,6 +910,17 @@ mod tests { 0 } + unsafe extern "C" fn persist_wallet_dashpay_backfill( + _context: *mut c_void, + _wallet_id: *const u8, + _floor: u32, + _rewound_from: u32, + _covered: *const crate::core_wallet_types::DashPayBackfillCoveredContactFFI, + _covered_count: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn persist_tracked_masternodes( _context: *mut c_void, _network: *const std::os::raw::c_char, @@ -1257,12 +1273,43 @@ mod tests { assert!(read_short.wallet_changeset_sweeps.is_none()); assert!(read_short.wallet_changeset_chain_lock_height.is_none()); assert!(read_short.wallet_changeset_utxo_verdicts.is_none()); + assert!(read_short.wallet_dashpay_backfill.is_none()); let read_unknown = unsafe { persistence_extension_callbacks(&unknown) }; assert!(read_unknown.dpns_name_states.is_none()); assert!(read_unknown.load_tracked_masternodes.is_none()); assert!(read_unknown.wallet_changeset_sweeps.is_none()); assert!(read_unknown.wallet_changeset_chain_lock_height.is_none()); assert!(read_unknown.wallet_changeset_utxo_verdicts.is_none()); + assert!(read_unknown.wallet_dashpay_backfill.is_none()); + } + + /// A host whose `struct_size` stops right after the identity + /// balance-block-time slots (built before the DashPay backfill slot + /// existed) keeps every earlier slot and simply never has the backfill + /// slot read; a host declaring the full size yields it. + #[test] + fn dashpay_backfill_slot_is_gated_by_struct_size() { + let without = PersistenceCallbacksExtension { + struct_size: std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_dashpay_backfill_fn + ), + on_persist_wallet_changeset_utxo_verdicts_fn: Some( + persist_wallet_changeset_utxo_verdicts, + ), + on_persist_wallet_dashpay_backfill_fn: Some(persist_wallet_dashpay_backfill), + ..Default::default() + }; + let read = unsafe { persistence_extension_callbacks(&without) }; + assert!(read.wallet_changeset_utxo_verdicts.is_some()); + assert!(read.wallet_dashpay_backfill.is_none()); + + let with = PersistenceCallbacksExtension { + on_persist_wallet_dashpay_backfill_fn: Some(persist_wallet_dashpay_backfill), + ..Default::default() + }; + let read = unsafe { persistence_extension_callbacks(&with) }; + assert!(read.wallet_dashpay_backfill.is_some()); } #[test] diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 7455fb2e16a..d163cb203c7 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -54,8 +54,9 @@ use crate::contact_persistence::{ }; use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{ - build_sweep_batches_for_callback, build_utxo_credit_verdicts_for_callback, - free_wallet_changeset_ffi, SweepBatchFFI, UtxoCreditVerdictFFI, WalletChangeSetFFI, + build_dashpay_backfill_covered_for_callback, build_sweep_batches_for_callback, + build_utxo_credit_verdicts_for_callback, free_wallet_changeset_ffi, + DashPayBackfillCoveredContactFFI, SweepBatchFFI, UtxoCreditVerdictFFI, WalletChangeSetFFI, }; use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; use crate::dpns_name_state_persistence::{ @@ -276,6 +277,25 @@ pub type LoadIdentityBalanceBlockTimeFn = unsafe extern "C" fn( out_block_time: *mut crate::types::BlockTime, ) -> i32; +/// Carries the wallet's DashPay coreHeight backfill record +/// ([`DashPayBackfillRecord`](platform_wallet::changeset::DashPayBackfillRecord)) +/// whenever a round writes one: the rescan sweep stores it on the same round +/// as the lowered `synced_height` it belongs with, so a host that stores +/// both has a record that vouches only for a cursor it also stored. Fired +/// inside the round's begin/end bracket, after the core changeset callback. +/// Every write is the whole record — `floor`, `rewound_from`, and every +/// covered contact — so the host replaces, never merges. A host without +/// the slot keeps today's behaviour: the backfill re-fires on every launch, +/// slow but never lossy (dashpay/platform#4302). +pub type PersistWalletDashPayBackfillFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + floor: u32, + rewound_from: u32, + covered: *const DashPayBackfillCoveredContactFFI, + covered_count: usize, +) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -406,6 +426,22 @@ pub struct PersistenceCallbacksExtension { out_block_time: *mut crate::types::BlockTime, ) -> i32, >, + /// The wallet's DashPay backfill record (see + /// [`PersistWalletDashPayBackfillFn`]). Appended under the same version + /// for the same reason as the slots above: `struct_size` proves whether + /// a host allocated it, and a host that did not simply never has it read + /// — which is exactly today's behaviour, a backfill that re-fires on + /// every launch. + pub on_persist_wallet_dashpay_backfill_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + floor: u32, + rewound_from: u32, + covered: *const DashPayBackfillCoveredContactFFI, + covered_count: usize, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -423,6 +459,7 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_changeset_utxo_verdicts_fn: None, on_persist_identity_balance_block_time_fn: None, on_load_identity_balance_block_time_fn: None, + on_persist_wallet_dashpay_backfill_fn: None, } } } @@ -441,6 +478,7 @@ pub struct PersistenceExtensionCallbacks { pub wallet_changeset_utxo_verdicts: Option, pub persist_identity_balance_block_time: Option, pub load_identity_balance_block_time: Option, + pub wallet_dashpay_backfill: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1372,6 +1410,11 @@ pub struct FFIPersister { /// materialises those rows unspent, as every host did before the slot /// existed. wallet_changeset_utxo_verdicts_callback: Option, + /// `Some` only when the host's extension `struct_size` proved the slot + /// was allocated. Carries the DashPay backfill record a round writes; a + /// host without it re-runs the backfill on every launch, as every host + /// did before the slot existed (dashpay/platform#4302). + wallet_dashpay_backfill_callback: Option, /// Additive tracked-masternode persistence trio (persist / load / /// free), likewise extension-negotiated. tracked_masternodes_callbacks: PersistenceExtensionCallbacks, @@ -1496,6 +1539,7 @@ impl FFIPersister { wallet_changeset_chain_lock_height_callback: extensions .wallet_changeset_chain_lock_height, wallet_changeset_utxo_verdicts_callback: extensions.wallet_changeset_utxo_verdicts, + wallet_dashpay_backfill_callback: extensions.wallet_dashpay_backfill, tracked_masternodes_callbacks: extensions, persist_identity_balance_block_time_callback: extensions .persist_identity_balance_block_time, @@ -1788,6 +1832,20 @@ impl PlatformWalletPersistence for FFIPersister { Ok(out) } + fn stores_dashpay_backfill(&self) -> bool { + // The record reaches the host only through its extension slot; a + // host without it cannot store one, nor replace one it holds. And + // coverage vouches for the cursor it rides with, which reaches the + // host only through the changeset callback: `store` withholds every + // covering record from a host without one, so claiming the record + // here would let the owning layer count coverage as stored that the + // host never received. Such a host is treated like any other that + // cannot keep coverage durably, which still sends it the empty + // record that clears coverage it holds. + self.wallet_dashpay_backfill_callback.is_some() + && self.callbacks.on_persist_wallet_changeset_fn.is_some() + } + fn store_commits_inline(&self) -> bool { // The end callback commits (or rolls back) the host transaction before // `store` returns. `flush` is only a later general-purpose notification. @@ -1811,6 +1869,28 @@ impl PlatformWalletPersistence for FFIPersister { // the sync mutex only adds waiting under genuine round contention. let mut round = self.round_lock.lock(); + // An empty backfill record is an invalidation: Rust sends one to + // clear coverage the host handed back on load, before publishing a + // wallet that will scan without it. A host without the backfill slot + // can still hand coverage back — its load path decodes the record + // whether or not the write slot is wired — but it cannot replace it. + // Acknowledging the round would leave that coverage on disk beside + // cursors scanned without its accounts, for a later session with the + // slot to trust. So the round fails before anything is applied, and + // the publication with it (dashpay/platform#4302 review). + if self.wallet_dashpay_backfill_callback.is_none() + && changeset + .dashpay_backfill + .as_ref() + .is_some_and(|record| record.is_empty()) + { + return Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Fatal, + "the host holds a DashPay backfill record but registered no backfill \ + callback to invalidate it", + )); + } + // Open the round on the Rust side (rejects a nested begin / an // unclean round left open by a prior unwind — error, never // panic). Matched 1:1 with the `round.end_round()` below. @@ -1845,6 +1925,13 @@ impl PlatformWalletPersistence for FFIPersister { } } let mut outcome = RoundOutcome::default(); + // Whether this round's `synced_height` reached the host. A backfill + // record rides the round that lowers the cursor it vouches for, and + // must not reach the host when that cursor did not: a host without + // atomic rounds would keep the coverage over its old cursor, and no + // rollback on this side can take it back (dashpay/platform#4302 + // review). + let mut cursor_delivered = false; // Wallet-registration metadata. Fires at most once per round // (registration emits the entry; subsequent rounds carry @@ -2114,6 +2201,8 @@ impl PlatformWalletPersistence for FFIPersister { result ); outcome.record(result); + } else if core_cs.synced_height.is_some() { + cursor_delivered = true; } } @@ -2178,6 +2267,75 @@ impl PlatformWalletPersistence for FFIPersister { } } + // The DashPay backfill record rides its own size-negotiated slot, + // after the core changeset callback so the lowered cursor a rescan + // round carries is staged before the record that vouches for it. + // Whole-record semantics: the host replaces what it holds. + // + // A host that registered no changeset callback stores no cursor at + // all, so no record can vouch for one it holds: it never receives + // the record, not even on a record-only round. Withholding only the + // cursor-bearing round is not enough — that round still returns + // `Ok`, the reconcile records its cursor as durable, and a later + // record-only round would then deliver coverage beside a host cursor + // that never moved. + // + // Nor does a host whose rounds are not atomic: callbacks that run + // after this one (identities, payments, tokens, shielded) can still + // fail the round, and such a host would keep the cursor and the + // coverage from a round Rust reports as failed — coverage that then + // suppresses the rescan on the next launch. Only a host attesting + // `ATOMIC_CHANGESETS` rolls the whole round back, record included. + // + // Both gates guard COVERAGE. An empty record covers no contact, so it + // vouches for no cursor and is safe beside any, on any host. It is + // how Rust invalidates coverage a host already holds: + // `settle_published_backfill` sends one before publishing a wallet to + // a host that cannot keep coverage durably, so the host cannot + // restore an earlier run's coverage beside cursors this run scanned + // without it. Withholding it here would let `store` return `Ok` with + // the old coverage still on disk. So an empty record goes to every + // host with the slot, and a failing callback fails the round, which + // fails the publication. A host without the slot never received a + // record, so it holds none to invalidate. + let host_stores_cursors = self.callbacks.on_persist_wallet_changeset_fn.is_some() + && self + .persistence_capabilities() + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS); + let backfill_cursor_owed = changeset + .core + .as_ref() + .is_some_and(|core| core.synced_height.is_some()); + if let Some(record) = changeset.dashpay_backfill.as_ref().filter(|record| { + record.is_empty() + || (host_stores_cursors && (!backfill_cursor_owed || cursor_delivered)) + }) { + if let Some(cb) = self.wallet_dashpay_backfill_callback { + let covered = build_dashpay_backfill_covered_for_callback(record); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + record.floor, + record.rewound_from, + if covered.is_empty() { + std::ptr::null() + } else { + covered.as_ptr() + }, + covered.len(), + ) + }; + if result != 0 { + eprintln!( + "Wallet DashPay backfill persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + } + // Send identity scalar changeset — upserts and removals. // Swift handler maps these onto `PersistentIdentity` row // upserts / tombstones. @@ -2593,6 +2751,7 @@ impl PlatformWalletPersistence for FFIPersister { established.note.as_deref(), established.is_hidden, &established.accepted_accounts, + established.external_account_reference, )); upserts.push(ContactRequestFFI::from_established_incoming( key.owner_id.to_buffer(), @@ -2606,6 +2765,7 @@ impl PlatformWalletPersistence for FFIPersister { // rides only the incoming row. established.contact_account_label.as_deref(), &established.accepted_accounts, + established.external_account_reference, )); } let removed_sent: Vec = contacts_cs @@ -5213,6 +5373,53 @@ fn provider_rebuild_error( /// the configured signer surface (see /// `Wallet::new_external_signable`). Earlier revisions of this code /// path produced a `WatchOnly` wallet — that has been replaced. +/// The persisted DashPay backfill record on a wallet-restore entry, or the +/// empty record when the host holds none. +/// +/// Empty is the fail-safe reading: a contact absent from the record is +/// re-armed by the next rescan sweep (one redundant rewind), whereas a +/// contact wrongly present would never be backfilled again. So a host that +/// reports the flag with a null cover set gets a record that covers no one, +/// and every entry is taken as the host stored it. +fn decode_dashpay_backfill( + entry: &WalletRestoreEntryFFI, +) -> platform_wallet::changeset::DashPayBackfillRecord { + use platform_wallet::changeset::{DashPayBackfillCoveredContact, DashPayBackfillRecord}; + + if !entry.has_dashpay_backfill { + return DashPayBackfillRecord::default(); + } + let covered: &[DashPayBackfillCoveredContactFFI] = + if entry.dashpay_backfill_covered.is_null() || entry.dashpay_backfill_covered_count == 0 { + &[] + } else { + // SAFETY: host-owned array of `count` entries, valid for the load + // callback window (the same contract as every sibling array). + unsafe { + slice::from_raw_parts( + entry.dashpay_backfill_covered, + entry.dashpay_backfill_covered_count, + ) + } + }; + // `from_entries` puts the cover set in canonical form whatever the host + // stored: one entry per account, a duplicate keeping its HIGHEST + // `covered_from`, so an ambiguous record fails closed. + DashPayBackfillRecord::from_entries( + entry.dashpay_backfill_floor, + entry.dashpay_backfill_rewound_from, + covered + .iter() + .map(|c| DashPayBackfillCoveredContact { + owner: Identifier::from(c.owner_identity_id), + contact: Identifier::from(c.contact_identity_id), + account_index: c.account_index, + covered_from: c.covered_from, + }) + .collect(), + ) +} + fn build_wallet_start_state( entry: &WalletRestoreEntryFFI, ) -> Result< @@ -5850,12 +6057,15 @@ fn build_wallet_start_state( // it. let unconfirmed_outgoing_txs = decode_unconfirmed_outgoing(entry); + let dashpay_backfill = decode_dashpay_backfill(entry); + let wallet_state = ClientWalletStartState { wallet, wallet_info, identity_manager, unused_asset_locks, unconfirmed_outgoing_txs, + dashpay_backfill, }; let platform_address_state = if per_account.is_empty() @@ -6376,6 +6586,7 @@ unsafe fn apply_contact_rows( is_hidden: bool, contact_account_label: Option, accepted_accounts: Vec, + external_account_reference: Option, } let opt_string = |ptr: *const std::os::raw::c_char| -> Option { @@ -6448,6 +6659,12 @@ unsafe fn apply_contact_rows( if acc.accepted_accounts.is_empty() { acc.accepted_accounts = u32s(row.accepted_accounts, row.accepted_accounts_len); } + // The outbound-account marker is relationship-level too; a host + // that never stored it hands back `false`, which restores `None` + // and keeps the conservative one-rebuild behaviour. + if acc.external_account_reference.is_none() && row.has_external_account_reference { + acc.external_account_reference = Some(row.external_account_reference); + } } for (contact_id_bytes, acc) in by_contact { @@ -6461,6 +6678,7 @@ unsafe fn apply_contact_rows( contact.payment_channel_broken = acc.payment_channel_broken; contact.contact_account_label = acc.contact_account_label; contact.accepted_accounts = acc.accepted_accounts; + contact.external_account_reference = acc.external_account_reference; managed.apply_established_contact(contact); } (Some(outgoing), None) => { @@ -8210,6 +8428,922 @@ mod tests { drop(persister); } + /// The DashPay backfill record reaches the host through its own + /// size-negotiated extension slot (dashpay/platform#4302): a round that + /// carries one fires it after the changeset callback with the whole + /// record — scalars and every covered contact — a round without one + /// never fires it, and a host without the slot still succeeds, keeping + /// the pre-record behaviour of a rewind per launch. + #[test] + fn store_delivers_the_dashpay_backfill_record_through_the_extension_slot() { + use platform_wallet::changeset::{CoreChangeSet, DashPayBackfillRecord}; + + #[derive(Default)] + struct Sink { + events: std::sync::Mutex>, + } + unsafe extern "C" fn record_changeset( + ctx: *mut c_void, + _wallet_id: *const u8, + _changeset: *const WalletChangeSetFFI, + ) -> i32 { + let sink = &*(ctx as *const Sink); + sink.events.lock().unwrap().push("changeset".into()); + 0 + } + unsafe extern "C" fn record_backfill( + ctx: *mut c_void, + wallet_id: *const u8, + floor: u32, + rewound_from: u32, + covered: *const DashPayBackfillCoveredContactFFI, + covered_count: usize, + ) -> i32 { + let sink = &*(ctx as *const Sink); + let wallet_id = std::slice::from_raw_parts(wallet_id, 32); + let covered: Vec = if covered.is_null() { + assert_eq!(covered_count, 0, "null cover set must carry count 0"); + Vec::new() + } else { + std::slice::from_raw_parts(covered, covered_count) + .iter() + .map(|c| { + format!( + "{}/{}@{}", + c.owner_identity_id[0], c.contact_identity_id[0], c.covered_from + ) + }) + .collect() + }; + sink.events.lock().unwrap().push(format!( + "backfill wallet={} floor={floor} rewound_from={rewound_from} covered=[{}]", + wallet_id[0], + covered.join(",") + )); + 0 + } + + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks, + PersistenceCapabilities::ATOMIC_CHANGESETS, + PersistenceExtensionCallbacks { + wallet_dashpay_backfill: Some(record_backfill), + ..Default::default() + }, + ); + + // A round with no record: the slot stays silent. + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(500), + ..CoreChangeSet::default() + }), + ..PlatformWalletChangeSet::default() + }, + ) + .expect("round without a record"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec!["changeset".to_string()] + ); + + // The rescan's round: lowered cursor first, then the whole record. + let mut record = DashPayBackfillRecord::default(); + record.record_pass( + 2_537_092, + Some(2_167_092), + [ + ( + dpp::prelude::Identifier::from([9u8; 32]), + dpp::prelude::Identifier::from([2u8; 32]), + 0, + 2_300_000, + ), + ( + dpp::prelude::Identifier::from([1u8; 32]), + dpp::prelude::Identifier::from([3u8; 32]), + 0, + 2_167_092, + ), + ], + ); + sink.events.lock().unwrap().clear(); + persister + .store( + [7u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(2_167_092), + ..CoreChangeSet::default() + }), + dashpay_backfill: Some(record), + ..PlatformWalletChangeSet::default() + }, + ) + .expect("rescan round"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec![ + "changeset".to_string(), + "backfill wallet=7 floor=2167092 rewound_from=2537092 \ + covered=[1/3@2167092,9/2@2300000]" + .to_string(), + ], + "the record follows the changeset callback, in record order" + ); + + // An empty cover set crosses as a null pointer with count 0. + sink.events.lock().unwrap().clear(); + persister + .store( + [7u8; 32], + PlatformWalletChangeSet { + dashpay_backfill: Some(DashPayBackfillRecord::default()), + ..PlatformWalletChangeSet::default() + }, + ) + .expect("empty record round"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec!["backfill wallet=7 floor=0 rewound_from=0 covered=[]".to_string()] + ); + + // A host without the slot: a covering record is withheld and the + // round still succeeds — Rust never offers it one, since such a host + // does not store the record. + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let legacy = FFIPersister::new_with_persistence_capabilities( + callbacks, + PersistenceCapabilities::NONE, + ); + assert!(!legacy.stores_dashpay_backfill()); + let covering = DashPayBackfillRecord::from_entries( + 100, + 1_000, + vec![platform_wallet::changeset::DashPayBackfillCoveredContact { + owner: Identifier::from([0xAA; 32]), + contact: Identifier::from([0xBB; 32]), + account_index: 0, + covered_from: 100, + }], + ); + legacy + .store( + [7u8; 32], + PlatformWalletChangeSet { + dashpay_backfill: Some(covering), + ..PlatformWalletChangeSet::default() + }, + ) + .expect("a host without the slot still stores the round"); + assert!(sink.events.lock().unwrap().is_empty()); + // But an empty record is an invalidation of coverage the host handed + // back, which it cannot perform: the round fails before anything is + // applied, rather than being acknowledged. + let refused = legacy.store( + [7u8; 32], + PlatformWalletChangeSet { + dashpay_backfill: Some(DashPayBackfillRecord::default()), + ..PlatformWalletChangeSet::default() + }, + ); + assert!(matches!( + refused, + Err(ref error) if error.kind() == Some(PersistenceErrorKind::Fatal) + )); + assert!(sink.events.lock().unwrap().is_empty()); + drop(persister); + } + + /// A backfill record rides the round that lowers the cursor it vouches + /// for, and must not reach the host when that cursor did not — a host + /// without atomic rounds would keep the coverage over its old cursor + /// (dashpay/platform#4302 review). Withheld when the changeset callback + /// fails for a round that carries a cursor, and on every round — + /// record-only ones included — for a host with no changeset callback, + /// which holds no cursor a record could vouch for, or whose rounds are + /// not atomic, so a later callback's failure could not roll the record + /// back. Delivered for a record-only round on an atomic host that stores + /// cursors. + #[test] + fn a_backfill_record_is_withheld_when_its_cursor_did_not_reach_the_host() { + use platform_wallet::changeset::{CoreChangeSet, DashPayBackfillRecord}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Default)] + struct Sink { + backfills: AtomicUsize, + } + unsafe extern "C" fn reject_changeset( + _ctx: *mut c_void, + _wallet_id: *const u8, + _changeset: *const WalletChangeSetFFI, + ) -> i32 { + 1 + } + unsafe extern "C" fn accept_changeset( + _ctx: *mut c_void, + _wallet_id: *const u8, + _changeset: *const WalletChangeSetFFI, + ) -> i32 { + 0 + } + unsafe extern "C" fn count_backfill( + ctx: *mut c_void, + _wallet_id: *const u8, + _floor: u32, + _rewound_from: u32, + _covered: *const DashPayBackfillCoveredContactFFI, + _covered_count: usize, + ) -> i32 { + (*(ctx as *const Sink)) + .backfills + .fetch_add(1, Ordering::SeqCst); + 0 + } + fn persister_with( + sink: &Sink, + changeset: Option, + atomic: bool, + ) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: sink as *const Sink as *mut c_void, + on_changeset_begin_fn: atomic.then_some(noop_begin as _), + on_changeset_end_fn: atomic.then_some(noop_end as _), + on_persist_wallet_changeset_fn: changeset, + ..PersistenceCallbacks::default() + }, + if atomic { + PersistenceCapabilities::ATOMIC_CHANGESETS + } else { + PersistenceCapabilities::NONE + }, + PersistenceExtensionCallbacks { + wallet_dashpay_backfill: Some(count_backfill), + ..Default::default() + }, + ) + } + let persister = |sink: &Sink, changeset: Option| { + persister_with(sink, changeset, true) + }; + type PersistWalletChangesetFnForTest = + unsafe extern "C" fn(*mut c_void, *const u8, *const WalletChangeSetFFI) -> i32; + // The gates guard coverage, so the rounds below carry a record that + // covers a contact; an empty one is an invalidation (see the end). + let covering = || { + let mut record = DashPayBackfillRecord::default(); + record.record_pass( + 1_000, + Some(100), + [( + Identifier::from([0xAA; 32]), + Identifier::from([0xBB; 32]), + 0, + 100, + )], + ); + record + }; + let rewind_round = || PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(100), + synced_height_is_rewind: true, + ..CoreChangeSet::default() + }), + dashpay_backfill: Some(covering()), + ..PlatformWalletChangeSet::default() + }; + + // The host rejected the cursor: the record must not follow it. + let sink = Sink::default(); + let rejected = persister(&sink, Some(reject_changeset)); + assert!(rejected.store([1u8; 32], rewind_round()).is_err()); + assert_eq!(sink.backfills.load(Ordering::SeqCst), 0); + + let record_only_round = || PlatformWalletChangeSet { + dashpay_backfill: Some(covering()), + ..PlatformWalletChangeSet::default() + }; + + // The host takes no cursors at all: a record over one is meaningless, + // on the rewind round and on every record-only round after it. + let sink = Sink::default(); + let cursorless = persister(&sink, None); + cursorless + .store([1u8; 32], rewind_round()) + .expect("nothing failed"); + cursorless + .store([1u8; 32], record_only_round()) + .expect("record-only round"); + assert_eq!(sink.backfills.load(Ordering::SeqCst), 0); + + // A host that stores cursors gets the record with its cursor, and a + // record-only round (no cursor to pair with) on its own. + let sink = Sink::default(); + let accepting = persister(&sink, Some(accept_changeset)); + accepting + .store([1u8; 32], rewind_round()) + .expect("rewind round"); + accepting + .store([1u8; 32], record_only_round()) + .expect("record-only round"); + assert_eq!(sink.backfills.load(Ordering::SeqCst), 2); + + // A host whose rounds are not atomic cannot roll back a cursor and a + // record whose round a later callback failed: it gets neither round's + // record. + let sink = Sink::default(); + let non_atomic = persister_with(&sink, Some(accept_changeset), false); + non_atomic + .store([1u8; 32], rewind_round()) + .expect("rewind round"); + non_atomic + .store([1u8; 32], record_only_round()) + .expect("record-only round"); + assert_eq!(sink.backfills.load(Ordering::SeqCst), 0); + + // An empty record covers no one, so it vouches for no cursor: it is + // an invalidation and reaches the cursorless and the non-atomic host + // alike, so neither can keep an earlier run's coverage + // (dashpay/platform#4302 review). + let invalidation = || PlatformWalletChangeSet { + dashpay_backfill: Some(DashPayBackfillRecord::default()), + ..PlatformWalletChangeSet::default() + }; + non_atomic + .store([1u8; 32], invalidation()) + .expect("invalidation"); + assert_eq!(sink.backfills.load(Ordering::SeqCst), 1); + let sink = Sink::default(); + let cursorless = persister(&sink, None); + cursorless + .store([1u8; 32], invalidation()) + .expect("invalidation"); + assert_eq!(sink.backfills.load(Ordering::SeqCst), 1); + } + + /// The lifecycle end of the invalidation above, through the real FFI + /// adapter, across sessions: an earlier session stored coverage the host + /// now hands back, and this session recreates the wallet without the + /// account it covers. The host's record must be cleared before the wallet + /// is published (dashpay/platform#4302 review): + /// - a host with the backfill slot but no atomic rounds gets the empty + /// record, and a failing callback fails the registration; + /// - a host WITHOUT the slot cannot clear it, so the registration fails + /// with a typed persistence error instead of being acknowledged; + /// - a host that hands back no record registers normally either way. + #[test] + fn a_recreated_wallet_must_clear_coverage_the_ffi_host_handed_back() { + use crate::event_handler::{EventHandlerCallbacks, FFIEventHandler}; + use crate::runtime::runtime; + use crate::wallet_restore_types::WalletRestoreEntryFFI; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use platform_wallet::changeset::PersistenceErrorKind; + use platform_wallet::{PlatformWalletError, PlatformWalletManager}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + struct Host { + wallet_id: [u8; 32], + holds_record: bool, + covered: [DashPayBackfillCoveredContactFFI; 1], + invalidations: AtomicUsize, + covering: AtomicUsize, + fail: AtomicBool, + } + unsafe extern "C" fn load_wallets( + ctx: *mut c_void, + entries: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + let host = &*(ctx as *const Host); + *entries = Box::into_raw(Box::new(WalletRestoreEntryFFI { + wallet_id: host.wallet_id, + has_dashpay_backfill: host.holds_record, + dashpay_backfill_floor: 100, + dashpay_backfill_rewound_from: 1_000, + dashpay_backfill_covered: if host.holds_record { + host.covered.as_ptr() + } else { + std::ptr::null() + }, + dashpay_backfill_covered_count: usize::from(host.holds_record), + ..Default::default() + })); + *count = 1; + 0 + } + unsafe extern "C" fn free_wallets( + _: *mut c_void, + entries: *const WalletRestoreEntryFFI, + _: usize, + ) { + drop(Box::from_raw(entries.cast_mut())); + } + unsafe extern "C" fn on_backfill( + ctx: *mut c_void, + _wallet_id: *const u8, + _floor: u32, + _rewound_from: u32, + _covered: *const DashPayBackfillCoveredContactFFI, + covered_count: usize, + ) -> i32 { + let host = &*(ctx as *const Host); + if covered_count == 0 { + host.invalidations.fetch_add(1, Ordering::SeqCst); + } else { + host.covering.fetch_add(1, Ordering::SeqCst); + } + i32::from(host.fail.load(Ordering::SeqCst)) + } + unsafe extern "C" fn accounts( + _: *mut c_void, + _: *const u8, + _: *const AccountSpecFFI, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn pools( + _: *mut c_void, + _: *const u8, + _: *const AccountAddressPoolFFI, + _: usize, + ) -> i32 { + 0 + } + // `atomic_accounts`: atomic rounds and both account writers, but no + // changeset callback, so no cursor reaches the host. + fn manager_for( + host: Option<&'static Host>, + slot: bool, + atomic_accounts: bool, + ) -> PlatformWalletManager { + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: host.map_or(std::ptr::null_mut(), |host| { + host as *const Host as *mut c_void + }), + on_load_wallet_list_fn: host.map(|_| load_wallets as _), + on_load_wallet_list_free_fn: host.map(|_| free_wallets as _), + on_changeset_begin_fn: atomic_accounts.then_some(noop_begin as _), + on_changeset_end_fn: atomic_accounts.then_some(noop_end as _), + on_persist_account_registrations_fn: atomic_accounts.then_some(accounts as _), + on_persist_account_address_pools_fn: atomic_accounts.then_some(pools as _), + ..PersistenceCallbacks::default() + }, + if atomic_accounts { + PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::ACCOUNT_ADDRESS_POOLS) + } else { + PersistenceCapabilities::NONE + }, + PersistenceExtensionCallbacks { + wallet_dashpay_backfill: slot.then_some(on_backfill as _), + ..Default::default() + }, + ); + let events = FFIEventHandler::new( + EventHandlerCallbacks { + context: std::ptr::null_mut(), + on_wallet_event_fn: None, + on_error_fn: None, + on_platform_address_sync_completed_fn: None, + on_shielded_sync_completed_fn: None, + on_shielded_sync_progress_fn: None, + on_shielded_tree_progress_fn: None, + release_fn: None, + }, + None, + ); + let _runtime_guard = runtime().enter(); + PlatformWalletManager::new( + Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(dashcore::Network::Testnet) + .build() + .expect("mock sdk"), + ), + Arc::new(persister), + Arc::new(events), + ) + } + let create = |manager: &PlatformWalletManager| { + runtime().block_on(manager.create_wallet_from_seed_bytes( + dashcore::Network::Testnet, + &[42; 64], + WalletAccountCreationOptions::Default, + Some(0), + )) + }; + let wallet_id = create(&manager_for(None, true, false)) + .expect("the id this seed registers under") + .wallet_id(); + let host = |holds_record: bool| -> &'static Host { + Box::leak(Box::new(Host { + wallet_id, + holds_record, + covered: [DashPayBackfillCoveredContactFFI { + owner_identity_id: [0xAA; 32], + contact_identity_id: [0xBB; 32], + account_index: 0, + covered_from: 200, + }], + invalidations: AtomicUsize::new(0), + covering: AtomicUsize::new(0), + fail: AtomicBool::new(false), + })) + }; + + // The slot, no atomic rounds: the invalidation is delivered, and a + // host that fails it fails the registration. + let with_slot = host(true); + with_slot.fail.store(true, Ordering::SeqCst); + let manager = manager_for(Some(with_slot), true, false); + assert!( + create(&manager).is_err(), + "an invalidation the host failed must fail the registration" + ); + with_slot.fail.store(false, Ordering::SeqCst); + create(&manager).expect("the invalidation lands"); + assert_eq!(with_slot.invalidations.load(Ordering::SeqCst), 2); + assert_eq!( + with_slot.covering.load(Ordering::SeqCst), + 0, + "a host that is not atomic never receives coverage" + ); + + // No slot: the coverage cannot be cleared, so nothing is published. + let manager = manager_for(Some(host(true)), false, false); + match create(&manager) { + Ok(_) => panic!("coverage the host cannot clear must block the registration"), + Err(PlatformWalletError::PersisterStore(source)) => { + assert_eq!(source.kind(), Some(PersistenceErrorKind::Fatal)); + } + Err(other) => panic!("expected a typed store failure, got {other:?}"), + } + assert!(runtime().block_on(manager.get_wallet(&wallet_id)).is_none()); + + // A host that hands back no record keeps the no-record fallback, + // with or without the slot. + for slot in [false, true] { + let clean = host(false); + create(&manager_for(Some(clean), slot, false)).expect("nothing to clear"); + assert_eq!(clean.invalidations.load(Ordering::SeqCst), 0); + } + + // Atomic rounds and both account writers, but no cursor callback: + // the host cannot keep coverage, so it is cleared like on any other + // such host and never offered coverage. + let cursorless = host(true); + create(&manager_for(Some(cursorless), true, true)).expect("the invalidation lands"); + assert_eq!(cursorless.invalidations.load(Ordering::SeqCst), 1); + assert_eq!(cursorless.covering.load(Ordering::SeqCst), 0); + } + + /// Coverage counts as durable on an FFI host only when everything it + /// rides with can reach the host: atomic rounds, both account writers, + /// the backfill slot, AND the changeset callback that carries the cursor + /// the coverage vouches for. `store` withholds every covering record + /// from a host without that callback, so advertising the record there + /// let the owning layer treat suppressed coverage as stored + /// (dashpay/platform#4302 review). + #[test] + fn backfill_coverage_needs_a_host_that_stores_its_cursor() { + use platform_wallet::changeset::persists_backfill_coverage; + + unsafe extern "C" fn begin(_: *mut c_void, _: *const u8) -> i32 { + 0 + } + unsafe extern "C" fn end(_: *mut c_void, _: *const u8, _: bool) -> i32 { + 0 + } + unsafe extern "C" fn accounts( + _: *mut c_void, + _: *const u8, + _: *const AccountSpecFFI, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn pools( + _: *mut c_void, + _: *const u8, + _: *const AccountAddressPoolFFI, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn changeset( + _: *mut c_void, + _: *const u8, + _: *const WalletChangeSetFFI, + ) -> i32 { + 0 + } + unsafe extern "C" fn backfill( + _: *mut c_void, + _: *const u8, + _: u32, + _: u32, + _: *const DashPayBackfillCoveredContactFFI, + _: usize, + ) -> i32 { + 0 + } + let host = |cursor_callback: bool, slot: bool| { + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + on_changeset_begin_fn: Some(begin), + on_changeset_end_fn: Some(end), + on_persist_account_registrations_fn: Some(accounts), + on_persist_account_address_pools_fn: Some(pools), + on_persist_wallet_changeset_fn: cursor_callback.then_some(changeset as _), + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::ACCOUNT_ADDRESS_POOLS), + PersistenceExtensionCallbacks { + wallet_dashpay_backfill: slot.then_some(backfill as _), + ..Default::default() + }, + ) + }; + assert!( + !persists_backfill_coverage(&host(false, true)), + "an atomic, account-capable host with the slot but no cursor callback" + ); + assert!(!persists_backfill_coverage(&host(true, false))); + assert!(persists_backfill_coverage(&host(true, true))); + } + + /// An atomic FFI host keeps outbound-account markers on disk only when it + /// also wires both account writers: `ACCOUNT_ADDRESS_POOLS` is derived + /// from them, and a host without them would acknowledge an account it + /// never stored beside a marker naming it (dashpay/platform#4302 review). + #[test] + fn outbound_markers_need_an_atomic_host_with_account_writers() { + use platform_wallet::changeset::persists_outbound_account_markers; + + unsafe extern "C" fn begin(_: *mut c_void, _: *const u8) -> i32 { + 0 + } + unsafe extern "C" fn end(_: *mut c_void, _: *const u8, _: bool) -> i32 { + 0 + } + unsafe extern "C" fn accounts( + _: *mut c_void, + _: *const u8, + _: *const AccountSpecFFI, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn pools( + _: *mut c_void, + _: *const u8, + _: *const AccountAddressPoolFFI, + _: usize, + ) -> i32 { + 0 + } + let declared = PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::ACCOUNT_ADDRESS_POOLS); + let host = |registrations: bool, address_pools: bool| { + FFIPersister::new_with_persistence_capabilities( + PersistenceCallbacks { + on_changeset_begin_fn: Some(begin), + on_changeset_end_fn: Some(end), + on_persist_account_registrations_fn: registrations.then_some(accounts as _), + on_persist_account_address_pools_fn: address_pools.then_some(pools as _), + ..PersistenceCallbacks::default() + }, + declared, + ) + }; + assert!(!persists_outbound_account_markers(&host(false, false))); + assert!(!persists_outbound_account_markers(&host(true, false))); + assert!(!persists_outbound_account_markers(&host(false, true))); + assert!(persists_outbound_account_markers(&host(true, true))); + } + + /// Why the outbound-account marker may ride an account's round only on + /// an atomic host: without begin/end brackets `FFIPersister` cannot claim + /// `ATOMIC_CHANGESETS` even when the host declares it, and it carries on + /// to the contacts callback after the account callback failed. A marker + /// in that round would land beside the account the host still holds, so + /// `register_external_contact_account` leaves it out of rounds on such a + /// host (dashpay/platform#4302 review). + #[test] + fn a_non_atomic_host_keeps_contact_rows_after_rejecting_the_account() { + use key_wallet::account::AccountType; + use key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; + use key_wallet::mnemonic::Mnemonic; + use platform_wallet::changeset::{ + AccountRegistrationEntry, ContactChangeSet, SentContactRequestKey, + }; + use platform_wallet::wallet::identity::{ContactRequest, EstablishedContact}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Default)] + struct Host { + marked_rows: AtomicUsize, + } + unsafe extern "C" fn reject_accounts( + _: *mut c_void, + _: *const u8, + _: *const AccountSpecFFI, + _: usize, + ) -> i32 { + 1 + } + #[allow(clippy::too_many_arguments)] + unsafe extern "C" fn keep_contacts( + ctx: *mut c_void, + _: *const u8, + upserts: *const ContactRequestFFI, + count: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const ContactIgnoredSenderFFI, + _: usize, + ) -> i32 { + let host = &*(ctx as *const Host); + for row in std::slice::from_raw_parts(upserts, count) { + if row.has_external_account_reference { + host.marked_rows.fetch_add(1, Ordering::SeqCst); + } + } + 0 + } + + let host = Host::default(); + let persister = FFIPersister::new_with_persistence_capabilities( + PersistenceCallbacks { + context: &host as *const Host as *mut c_void, + on_persist_account_registrations_fn: Some(reject_accounts), + on_persist_contacts_fn: Some(keep_contacts), + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::ATOMIC_CHANGESETS, + ); + assert!( + !persister + .persistence_capabilities() + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS), + "a declaration without begin/end brackets is not an atomic round" + ); + + let seed = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .expect("static BIP-39 vector") + .to_seed(""); + let master = + ExtendedPrivKey::new_master(dashcore::Network::Testnet, &seed).expect("master"); + let xpub = ExtendedPubKey::from_priv(&master); + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0x01; 32]); + let mut established = EstablishedContact::new( + contact, + ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0), + ContactRequest::new(contact, owner, 0, 0, 8, vec![0u8; 96], 100, 0), + ); + established.external_account_reference = Some(8); + let mut contacts = ContactChangeSet::default(); + contacts.established.insert( + SentContactRequestKey { + owner_id: owner, + recipient_id: contact, + }, + established, + ); + let round = PlatformWalletChangeSet { + account_registrations: vec![AccountRegistrationEntry { + account_type: AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }, + account_xpub: xpub, + }], + contacts: Some(contacts), + ..PlatformWalletChangeSet::default() + }; + assert!(persister.store([7u8; 32], round).is_err()); + assert!( + host.marked_rows.load(Ordering::SeqCst) > 0, + "the contact rows still reached the host after the account was rejected" + ); + } + + /// The restore side of dashpay/platform#4302: a wallet-restore entry + /// carrying the record decodes into the start state's + /// `dashpay_backfill` in canonical form, and an entry without it — the + /// zero-init default every pre-record host produces — decodes as the + /// empty record, so the rescan sweep rewinds once as it always did. + #[test] + fn wallet_restore_decodes_the_dashpay_backfill_record() { + use platform_wallet::changeset::DashPayBackfillRecord; + + let none = WalletRestoreEntryFFI::default(); + assert!(!none.has_dashpay_backfill); + assert!(super::decode_dashpay_backfill(&none).is_empty()); + + // The flag alone, with a null cover set: a record that covers no + // one, never a torn read. + let bare = WalletRestoreEntryFFI { + has_dashpay_backfill: true, + dashpay_backfill_floor: 10, + dashpay_backfill_rewound_from: 20, + ..Default::default() + }; + let decoded = super::decode_dashpay_backfill(&bare); + assert!(decoded.is_empty()); + assert_eq!((decoded.floor, decoded.rewound_from), (10, 20)); + + // Out of order and duplicated, as a host might hand it back: the + // decoded record is sorted with one entry per account, the highest + // height of a duplicate kept. + let covered = [ + DashPayBackfillCoveredContactFFI { + owner_identity_id: [9u8; 32], + contact_identity_id: [2u8; 32], + account_index: 0, + covered_from: 2_300_000, + }, + DashPayBackfillCoveredContactFFI { + owner_identity_id: [1u8; 32], + contact_identity_id: [3u8; 32], + account_index: 0, + covered_from: 2_167_092, + }, + DashPayBackfillCoveredContactFFI { + owner_identity_id: [9u8; 32], + contact_identity_id: [2u8; 32], + account_index: 0, + covered_from: 2_999_999, + }, + ]; + let entry = WalletRestoreEntryFFI { + has_dashpay_backfill: true, + dashpay_backfill_floor: 2_167_092, + dashpay_backfill_rewound_from: 2_537_092, + dashpay_backfill_covered: covered.as_ptr(), + dashpay_backfill_covered_count: covered.len(), + ..Default::default() + }; + let decoded = super::decode_dashpay_backfill(&entry); + let mut expected = DashPayBackfillRecord::default(); + expected.record_pass( + 2_537_092, + Some(2_167_092), + [ + ( + dpp::prelude::Identifier::from([1u8; 32]), + dpp::prelude::Identifier::from([3u8; 32]), + 0, + 2_167_092, + ), + ( + dpp::prelude::Identifier::from([9u8; 32]), + dpp::prelude::Identifier::from([2u8; 32]), + 0, + 2_999_999, + ), + ], + ); + assert_eq!(decoded, expected); + assert_eq!( + decoded.covered_from( + &dpp::prelude::Identifier::from([9u8; 32]), + &dpp::prelude::Identifier::from([2u8; 32]), + 0 + ), + Some(2_999_999), + "a duplicated account keeps its highest height, so the record fails closed" + ); + } + /// The numeric chainlock height reaches the host through its own /// size-negotiated extension slot: a chainlock-advancing round fires it /// after the changeset callback with the height a non-Rust host cannot @@ -8705,6 +9839,16 @@ mod tests { PersistenceCallbacksExtension, on_load_identity_balance_block_time_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_dashpay_backfill_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_dashpay_backfill_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -10137,6 +11281,7 @@ mod tests { Some("a note"), true, &[7, 42], + Some(4), ), ContactRequestFFI::from_established_incoming( owner.to_buffer(), @@ -10148,6 +11293,7 @@ mod tests { true, Some("Main wallet"), &[7, 42], + Some(4), ), ]; @@ -10204,6 +11350,11 @@ mod tests { assert_eq!(e.note.as_deref(), Some("a note")); assert!(e.is_hidden); assert!(e.payment_channel_broken); + assert_eq!( + e.external_account_reference, + Some(4), + "the outbound-account marker must survive the restore (dashpay/platform#4302)" + ); assert_eq!( e.contact_account_label.as_deref(), Some("Main wallet"), diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index 22a71ade25b..a835b46b6f0 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -718,6 +718,25 @@ pub struct WalletRestoreEntryFFI { /// leaves every existing field where it was. pub unconfirmed_outgoing_tx_records: *const UnconfirmedOutgoingTxRecordFFI, pub unconfirmed_outgoing_tx_records_count: usize, + /// Whether the host holds a persisted DashPay backfill record for this + /// wallet (dashpay/platform#4302). `false` — the zero-init default — for + /// a wallet that never rewound for a contact, a row persisted before the + /// record existed, or a host that does not store the slot; the rescan + /// sweep then behaves as it always did and writes one. The three fields + /// below are read only when this is `true`. + pub has_dashpay_backfill: bool, + /// Lowest height the backfill rewound the cursor to. + pub dashpay_backfill_floor: u32, + /// Highest cursor the backfill rewound from — the height the scan climbs + /// back to for the backfill to be complete. + pub dashpay_backfill_rewound_from: u32, + /// Receival contacts the backfill covers, each with the height it is + /// covered from. Host-owned for the callback window, freed by + /// `LoadWalletListFreeFn`; `null` / `0` when the record covers no one. + /// Appended at the end, like every addition to this `#[repr(C)]` struct, + /// so no existing offset moves. + pub dashpay_backfill_covered: *const crate::core_wallet_types::DashPayBackfillCoveredContactFFI, + pub dashpay_backfill_covered_count: usize, } /// Every field named explicitly so that adding a field to this ABI struct @@ -758,6 +777,11 @@ impl Default for WalletRestoreEntryFFI { last_applied_chain_lock_bytes_len: 0, unconfirmed_outgoing_tx_records: std::ptr::null(), unconfirmed_outgoing_tx_records_count: 0, + has_dashpay_backfill: false, + dashpay_backfill_floor: 0, + dashpay_backfill_rewound_from: 0, + dashpay_backfill_covered: std::ptr::null(), + dashpay_backfill_covered_count: 0, } } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 9c2a11f7cd7..82fda2837ca 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1859,6 +1859,10 @@ fn load_one_wallet( // replay inert here, which is the behaviour this path had before the // field existed. unconfirmed_outgoing_txs: Vec::new(), + // Not persisted by this backend yet: the backfill record rides the + // FFI hosts' wallet row only. Empty keeps the rescan sweep on its + // pre-record behaviour here (a rewind per launch, never lossy). + dashpay_backfill: Default::default(), }) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs index bfbb9af6486..de2b418cae1 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs @@ -144,6 +144,10 @@ pub fn touched_domains(cs: &PlatformWalletChangeSet) -> Vec { invitations, dpns_name_states, identity_scan_state, + // Not persisted by this backend yet (see `ClientWalletStartState:: + // dashpay_backfill` on the load side): the record rides the FFI + // hosts' wallet row only, so it touches no domain here. + dashpay_backfill: _, #[cfg(feature = "shielded")] shielded, #[cfg(not(feature = "shielded"))] diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 702b8abd2d0..f2c5a9f1795 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -34,6 +34,7 @@ use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType}; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::{AddressInfo, Network, PlatformP2PKHAddress, Utxo}; +use crate::changeset::dashpay_backfill::DashPayBackfillRecord; use crate::changeset::identity_scan_state::IdentityScanStateEntry; use crate::wallet::platform_wallet::WalletId; @@ -163,6 +164,22 @@ pub struct CoreChangeSet { /// on merge. pub synced_height: Option, + /// Whether [`synced_height`](Self::synced_height) is a REWIND rather than + /// an advance. The DashPay rescan reconcile lowers the durable cursor + /// deliberately and stores the lowered value with the backfill record + /// that vouches for it; that value must replace whatever the round is + /// merged behind instead of losing to the ordinary monotonic max. + /// + /// Merge is an ordered reset/advance composition — `advance(a)·advance(b) + /// = advance(max)`, `x·rewind(b) = rewind(b)`, `rewind(a)·advance(b) = + /// rewind(max(a, b))` — which is associative, so how a backend groups + /// rounds into accumulations never changes the result. Meaningless while + /// `synced_height` is `None`. Hosts that write the cursor they are handed + /// honour it directly; the SQLite backend keeps its monotonic max and + /// does not store the backfill record, so it neither needs nor sees it. + #[cfg_attr(feature = "serde", serde(default))] + pub synced_height_is_rewind: bool, + /// Addresses freshly derived as a side effect of processing the /// records in this batch — i.e. `WalletEvent::TransactionDetected. /// addresses_derived` and `WalletEvent::BlockProcessed. @@ -748,7 +765,21 @@ impl Merge for CoreChangeSet { ); } if let Some(h) = other.synced_height { - self.synced_height = Some(self.synced_height.map_or(h, |existing| existing.max(h))); + if other.synced_height_is_rewind { + // A rewind replaces whatever it is merged behind. + self.synced_height = Some(h); + self.synced_height_is_rewind = true; + } else { + // An advance composes by max, and keeps a rewind a rewind: + // `rewind(a)·advance(b)` still has to replace anything an + // outer accumulation merges this result behind. A flag with + // no height beside it is meaningless, so it must not turn + // this advance into a rewind. + if self.synced_height.is_none() { + self.synced_height_is_rewind = false; + } + self.synced_height = Some(self.synced_height.map_or(h, |existing| existing.max(h))); + } } // Derived-address dedup. Within a single `WalletEvent` the @@ -2155,6 +2186,22 @@ pub struct PlatformWalletChangeSet { /// scan is retried inside its own launch — but honouring the verdict /// across launches needs the host slot. pub identity_scan_state: Option, + /// Durable record of the DashPay coreHeight backfill — which receival + /// contacts the historical rescan covers, from which height each, and + /// the extent of the rescan. Written by `reconcile_dashpay_rescan` on the + /// same round as the lowered `core.synced_height` it belongs with, and + /// read back through + /// [`ClientWalletStartState::dashpay_backfill`](crate::changeset::ClientWalletStartState::dashpay_backfill) + /// so a fresh process does not rewind for a contact the previous one + /// already rewound for (dashpay/platform#4302). Every write carries the + /// whole record, so merge is last-write-wins. The lowered cursor it + /// travels with is marked [`CoreChangeSet::synced_height_is_rewind`], so + /// it survives any accumulation. See [`DashPayBackfillRecord`]. + /// + /// Durability caveat, the same one `identity_scan_state` carries: a host + /// that has not adopted the slot keeps today's behaviour — the backfill + /// re-fires on every launch — which is slow but never lossy. + pub dashpay_backfill: Option, /// Per-account registration entries emitted at registration / on /// later `add_account` calls. See [`AccountRegistrationEntry`] for /// the merge policy (plain `Vec::extend`, dedup is the apply-side @@ -2306,6 +2353,11 @@ impl Merge for PlatformWalletChangeSet { None => scan, }); } + // Backfill record: every write is a whole snapshot, so the later + // one wins outright. + if let Some(record) = other.dashpay_backfill { + self.dashpay_backfill = Some(record); + } // Per-account specs and address-pool snapshots: append-only. // See the type docstrings for the rationale (registration // round emits each key once; snapshots are whole-pool, so @@ -2342,6 +2394,7 @@ impl Merge for PlatformWalletChangeSet { .is_none_or(|m| m.is_empty()) && self.wallet_metadata.is_none() && self.identity_scan_state.is_none() + && self.dashpay_backfill.is_none() && self.account_registrations.is_empty() && self.provider_key_account_registrations.is_empty() && self.account_address_pools.is_empty() @@ -3337,6 +3390,119 @@ mod tests { assert_eq!(cs.addresses_marked_used.len(), 3); } + /// The rewind marker composes associatively: however a backend groups + /// rounds into accumulations, a rewind replaces what precedes it, an + /// advance after a rewind raises it and keeps it a rewind, and a round + /// that carries only a backfill record changes no cursor. The earlier + /// version inferred "rewind" from a record and a cursor riding one round, + /// which grouping could manufacture (dashpay/platform#4302 review). + #[test] + fn the_rewind_marker_composes_associatively() { + use crate::changeset::DashPayBackfillRecord; + + fn advance(h: u32) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(h), + ..CoreChangeSet::default() + }), + ..PlatformWalletChangeSet::default() + } + } + fn rewind(h: u32) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(h), + synced_height_is_rewind: true, + ..CoreChangeSet::default() + }), + dashpay_backfill: Some(DashPayBackfillRecord::default()), + ..PlatformWalletChangeSet::default() + } + } + // A changeset carrying the flag but no height, as an external + // constructor could build one: the flag is meaningless there. + fn heightless(flag: bool) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: None, + synced_height_is_rewind: flag, + ..CoreChangeSet::default() + }), + ..PlatformWalletChangeSet::default() + } + } + fn record_only() -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + dashpay_backfill: Some(DashPayBackfillRecord::default()), + ..PlatformWalletChangeSet::default() + } + } + fn fold(parts: Vec) -> PlatformWalletChangeSet { + let mut acc = PlatformWalletChangeSet::default(); + for part in parts { + acc.merge(part); + } + acc + } + fn cursor(cs: &PlatformWalletChangeSet) -> (Option, bool) { + cs.core.as_ref().map_or((None, false), |core| { + (core.synced_height, core.synced_height_is_rewind) + }) + } + // Both parenthesizations of every triple. + type Case = ( + &'static str, + fn() -> Vec, + (Option, bool), + ); + let cases: Vec = vec![ + ( + "advance, lower advance, record-only", + || vec![advance(1_000), advance(100), record_only()], + (Some(1_000), false), + ), + ( + "advance, rewind, later advance", + || vec![advance(1_000), rewind(100), advance(150)], + (Some(150), true), + ), + ( + "rewind, advance, rewind", + || vec![rewind(500), advance(900), rewind(200)], + (Some(200), true), + ), + ( + "advance, advance, rewind", + || vec![advance(1_000), advance(1_200), rewind(100)], + (Some(100), true), + ), + ( + "advance, heightless flagged, lower advance", + || vec![advance(1_000), heightless(true), advance(100)], + (Some(1_000), false), + ), + ( + "advance, heightless unflagged, lower advance", + || vec![advance(1_000), heightless(false), advance(100)], + (Some(1_000), false), + ), + ( + "rewind, heightless flagged, advance", + || vec![rewind(500), heightless(true), advance(900)], + (Some(900), true), + ), + ]; + for (name, parts, expected) in cases { + let [a, b, c]: [PlatformWalletChangeSet; 3] = parts().try_into().ok().unwrap(); + let left = fold(vec![fold(vec![a, b]), c]); + let [a, b, c]: [PlatformWalletChangeSet; 3] = parts().try_into().ok().unwrap(); + let right = fold(vec![a, fold(vec![b, c])]); + assert_eq!(cursor(&left), expected, "{name}: (A·B)·C"); + assert_eq!(cursor(&right), expected, "{name}: A·(B·C)"); + } + } + /// Highest-used watermarks merge monotonic-max per account per /// pool slot: a later batch can only advance a slot, and `None` /// never erases a prior `Some`. diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 6eab3d88780..90f18b08e5a 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; +use crate::changeset::dashpay_backfill::DashPayBackfillRecord; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::{OutPoint, Transaction}; @@ -60,4 +61,11 @@ pub struct ClientWalletStartState { /// permanently. Replaying the record at load restores exactly the /// state the live process held. pub unconfirmed_outgoing_txs: Vec, + /// Durable record of the DashPay coreHeight backfill, as the host last + /// stored it — which receival contacts the historical rescan already + /// covers, from which height each. Empty (the default) when the host + /// never stored one, or does not persist the slot at all; the first + /// rescan sweep then behaves exactly as before and writes one. See + /// [`DashPayBackfillRecord`]. + pub dashpay_backfill: DashPayBackfillRecord, } diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 570b3d4d83b..b752f465d88 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -182,6 +182,9 @@ struct BatchDiagnostics { wallets: usize, /// Highest watermark the store accepted and committed. persisted: Option, + /// The watermark each wallet's store accepted in this drain — what that + /// wallet's host now holds, recorded into [`DurableCursors`]. + persisted_by_wallet: BTreeMap, /// Highest watermark withheld by the fail-closed guard. frozen: Option, /// Highest watermark the store rejected. @@ -268,6 +271,249 @@ pub fn spawn_wallet_event_adapter

( sync_fault: Arc, cancel: CancellationToken, ) -> JoinHandle<()> +where + P: PlatformWalletPersistence + 'static, +{ + spawn_wallet_event_adapter_with_durable_cursors( + wallet_manager, + persister, + receiver, + sync_fault, + crate::changeset::DurableCursors::default(), + cancel, + ) +} + +/// The durable `synced_height` each wallet's host last accepted, shared by +/// every writer of that cursor and doubling as the lock that orders them. +/// +/// Two writers exist: the wallet-event adapter (advances, one per committed +/// batch) and the DashPay rescan reconcile (a deliberate rewind stored with +/// the backfill record that vouches for it). Without one lock across +/// "decide the height" and "store it", an advance the adapter had already +/// projected could be stored after the rewind and put the durable cursor back +/// above the range still to be re-matched, and a rewind could advance the +/// durable cursor past rows the adapter had not yet persisted +/// (dashpay/platform#4302 review). Both take this lock before they read the +/// wallet's in-memory cursor and hold it until their `store()` returns; the +/// adapter strips, at commit time, an advance a rewind has overtaken (see +/// [`RewindBarrier`]) or one above the in-memory cursor, and the reconcile +/// never writes a cursor at or above the recorded durable one. +/// +/// Lock order is this mutex, then the wallet manager — never the reverse. +/// Seeded with the cursor each wallet was loaded or created with. +/// +/// An entry outlives its wallet: a removed wallet's entry stays what its host +/// row holds, and a same-id successor seeds from it in preference to the +/// snapshot it read before taking the lock, which a predecessor's commit may +/// have overtaken. The same structure carries the removed wallet's +/// [`RetiredBarrier`] for the successor to inherit — and for the adapter to +/// count down while no wallet is registered under the id, since advances the +/// removed wallet emitted keep arriving and are dropped meanwhile. +pub type DurableCursors = Arc; + +/// The shared state behind [`DurableCursors`]. +#[derive(Debug, Default)] +pub struct DurableCursorState { + /// Per wallet, what its host holds. The ordering lock. + pub cursors: Arc>>, + /// Per wallet id with no registered wallet, the barrier state the removed + /// wallet left (see [`RewindBarrier::retire`]). A leaf lock: taken under + /// the manager lock on both sides, never held across an await. + pub retired: std::sync::Mutex>, +} + +impl DurableCursorState { + /// Lock the cursor map for a short, non-blocking critical section. + pub async fn lock(&self) -> tokio::sync::MutexGuard<'_, BTreeMap> { + self.cursors.lock().await + } + + /// The retired-barrier map, poison-tolerant. + pub fn retired(&self) -> std::sync::MutexGuard<'_, BTreeMap> { + self.retired + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// What a removed wallet leaves under its id for a same-id successor +/// (dashpay/platform#4302 review). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RetiredBarrier { + /// Advances the removed wallet emitted that the adapter has not projected + /// yet. Counted down as they arrive while no wallet holds the id (they + /// are dropped), so a successor inherits exactly the ones still queued. + pub in_flight: u64, + /// The removed wallet's rewind epoch. + pub epoch: u64, + /// The removed wallet's final `account_generation`. The successor's + /// starts above it, so a filter batch whose scripts were reconciled + /// against the predecessor's account set never matches the successor's + /// generation and is not certified for it. + pub account_generation: u64, +} + +/// What one wallet's host holds, as [`DurableCursors`] records it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DurableCursor { + /// The `synced_height` the host last accepted. + pub height: u32, + /// The [`RewindBarrier`] epoch of the scan advance that put `height` + /// there, when it was one; `None` for a cursor seeded at load or + /// creation, or written by a backfill round as a rewind. + /// + /// A cursor owed to the host after an in-memory lowering is paid in full + /// once the host accepts an advance from the lowering's epoch or later: + /// that advance was reached by the scan that restarted from the lowered + /// cursor, so the host already tracks it, and writing the owed height + /// afterwards would only drag the host back below the scan + /// (dashpay/platform#4302 device check). + pub advance_epoch: Option, +} + +impl DurableCursor { + /// A cursor the host holds without a known scan advance behind it. + pub fn at(height: u32) -> Self { + Self { + height, + advance_epoch: None, + } + } +} + +/// Orders one wallet's `SyncHeightAdvanced` events against in-memory rewinds +/// of its scan cursor, so an advance the engine emitted *before* a rewind is +/// never persisted after it. +/// +/// A rewind emits nothing (the engine only reports forward moves), so the +/// persistence channel carries no marker of where one happened, and a +/// height comparison cannot place it either: once the rescan has climbed +/// back past a still-queued pre-rewind advance, that advance looks current, +/// and storing it would put the durable cursor above transaction rows the +/// rescan emitted behind it in the channel (dashpay/platform#4302 review). +/// +/// The barrier counts instead. The engine emits a `SyncHeightAdvanced` only +/// right after calling this wallet's `update_synced_height` hook, under the +/// same manager write lock, so the hook counts emissions exactly +/// ([`note_emitted`](Self::note_emitted)), and the adapter counts them back +/// down as it projects them ([`take_next`](Self::take_next)). A rewind — +/// made under that same write lock — snapshots how many are still in flight +/// ([`arm`](Self::arm)): exactly those were emitted before it, and the +/// adapter drops them as they arrive. Every later advance is tagged with the +/// rewind epoch it was projected in, and a commit strips one whose epoch a +/// rewind has since superseded, which covers an advance the adapter had +/// already projected when the rewind ran. +/// +/// Miscounting fails safe: an extra count only drops a later, genuine +/// advance, leaving the durable cursor behind the scan until the next one. +#[derive(Debug, Default)] +pub struct RewindBarrier { + /// `SyncHeightAdvanced` events emitted for this wallet that the adapter + /// has not projected yet. + in_flight: u64, + /// How many of the next projected advances were emitted before the + /// latest rewind. + stale: u64, + /// Bumped by every rewind. + epoch: u64, + /// Inherited from a removed same-id predecessor: one past its final + /// `account_generation`, folded into this wallet's. + generation_base: u64, + /// Receival accounts platform registered by inserting them directly + /// (not through key-wallet's `add_managed_account*`, which bump the + /// engine's own counter). Folded into the wallet's `account_generation` + /// so the filter pipeline refuses to certify a batch scanned before the + /// account's scripts were watched. + account_registrations: u64, +} + +impl RewindBarrier { + /// The engine is about to emit a `SyncHeightAdvanced` for this wallet. + pub(crate) fn note_emitted(&mut self) { + self.in_flight = self.in_flight.saturating_add(1); + } + + /// The scan cursor was just lowered in memory: every advance still in + /// flight predates it. + pub(crate) fn arm(&mut self) { + self.stale = self.in_flight; + self.epoch = self.epoch.wrapping_add(1); + } + + /// Classify the next advance the adapter projects: `None` when it was + /// emitted before the latest rewind, else the epoch it belongs to. + pub(crate) fn take_next(&mut self) -> Option { + self.in_flight = self.in_flight.saturating_sub(1); + if self.stale > 0 { + self.stale -= 1; + None + } else { + Some(self.epoch) + } + } + + /// The current rewind epoch. + pub(crate) fn epoch(&self) -> u64 { + self.epoch + } + + /// What a removed wallet leaves for a successor registered under the same + /// id: its advances still in flight, its epoch, and its final account + /// generation (`account_generation`, the wallet's full value). + pub(crate) fn retire(&self, account_generation: u64) -> RetiredBarrier { + RetiredBarrier { + in_flight: self.in_flight, + epoch: self.epoch, + account_generation, + } + } + + /// Take over a removed predecessor's [`retire`](Self::retire) state. The + /// shared adapter can still hold advances the predecessor emitted, queued + /// or already projected; all of them predate this wallet's own scan, so + /// the queued ones are counted as stale and the epoch moves past the + /// predecessor's, which strips the projected ones at commit. The account + /// generation moves past the predecessor's too, so no in-flight filter + /// batch scanned against its account set certifies this wallet. + pub(crate) fn inherit(&mut self, retired: RetiredBarrier) { + self.in_flight = self.in_flight.saturating_add(retired.in_flight); + self.stale = self.stale.saturating_add(retired.in_flight); + self.epoch = self.epoch.wrapping_add(retired.epoch).wrapping_add(1); + self.generation_base = self + .generation_base + .max(retired.account_generation.saturating_add(1)); + } + + /// See [`inherit`](Self::inherit): added to this wallet's account + /// generation. + pub(crate) fn generation_base(&self) -> u64 { + self.generation_base + } + + /// A receival account was inserted outside key-wallet's account-add + /// API: invalidate every in-flight scan snapshot of the account set. + pub(crate) fn note_account_registered(&mut self) { + self.account_registrations = self.account_registrations.wrapping_add(1); + } + + /// See [`note_account_registered`](Self::note_account_registered). + pub(crate) fn account_registrations(&self) -> u64 { + self.account_registrations + } +} + +/// [`spawn_wallet_event_adapter`] sharing `durable_cursors` with the other +/// writer of the durable sync cursor (the DashPay rescan reconcile). The +/// manager uses this form; see [`DurableCursors`]. +pub fn spawn_wallet_event_adapter_with_durable_cursors

( + wallet_manager: Arc>>, + persister: Weak

, + receiver: mpsc::UnboundedReceiver, + sync_fault: Arc, + durable_cursors: DurableCursors, + cancel: CancellationToken, +) -> JoinHandle<()> where P: PlatformWalletPersistence + 'static, { @@ -276,10 +522,138 @@ where persister, receiver, sync_fault, + durable_cursors, cancel, )) } +/// Project one event and fold it into its wallet's entry of `batch`, in +/// channel order. +/// +/// A `SyncHeightAdvanced` is first classified against the wallet's +/// [`RewindBarrier`]: one emitted before the latest rewind contributes no +/// height, and the first advance of a newer rewind epoch replaces the +/// batch's height instead of max-merging with it — a pre-rewind height is +/// higher than the rescan's by construction, and the max would keep it. +/// +/// An advance for a wallet id with no wallet registered under it — removed, +/// or rolled back — contributes nothing: nothing may persist a cursor for a +/// wallet that is gone, and a same-id successor must not inherit it. It is +/// counted down from the id's [`RetiredBarrier`] so the successor inherits +/// exactly the advances still queued (dashpay/platform#4302 review). +async fn project_event_into_batch( + wallet_manager: &Arc>>, + durable_cursors: &DurableCursors, + event: &WalletEvent, + batch: &mut BTreeMap, +) { + let wallet_id = event.wallet_id(); + let advance_epoch = match event { + WalletEvent::SyncHeightAdvanced { .. } => Some({ + let mut wm = wallet_manager.write().await; + match wm.get_wallet_info_mut(&wallet_id) { + Some(info) => info.rewind_barrier.take_next(), + None => { + if let Some(retired) = durable_cursors.retired().get_mut(&wallet_id) { + retired.in_flight = retired.in_flight.saturating_sub(1); + } + None + } + } + }), + _ => None, + }; + let mut core = build_core_changeset(wallet_manager, event).await; + let asset_locks = reconstruct_asset_locks_for_event(wallet_manager, event).await; + let payments = sent_payment_verdicts(wallet_manager, event).await; + let entry = batch.entry(wallet_id).or_default(); + match advance_epoch { + Some(None) => { + tracing::debug!( + wallet_id = %hex::encode(wallet_id), + projected = ?core.synced_height, + "wallet-event adapter: dropping a sync-height advance emitted before a rewind" + ); + core.synced_height = None; + core.synced_height_is_rewind = false; + } + Some(Some(epoch)) if core.synced_height.is_some() => { + if entry.advance_epoch.is_some_and(|held| held != epoch) { + entry.core.synced_height = None; + entry.core.synced_height_is_rewind = false; + } + entry.advance_epoch = Some(epoch); + } + _ => {} + } + entry.core.merge(core); + entry.asset_locks.merge(asset_locks); + // Last-write-wins per `(owner, txid)`: a transaction swept and then + // reinstated inside one drain reaches the store as the verdict the drain + // ended on, never as two rows. + merge_payment_overlays(&mut entry.payments, payments); +} + +/// Strip, from each wallet's batch, a sync-height advance a rewind has +/// overtaken: one projected in a [`RewindBarrier`] epoch a rewind has since +/// superseded, one above the wallet's in-memory cursor, or one for a wallet +/// id nothing is registered under any more. Storing either +/// would claim coverage the rescan has not produced. Called with +/// [`DurableCursors`] held, right before commit, so no rewind can slip in +/// between this check and the store. +async fn drop_watermarks_above_the_live_cursor( + wallet_manager: &Arc>>, + batch: &mut BTreeMap, +) { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let wm = wallet_manager.read().await; + for (wallet_id, wallet_batch) in batch.iter_mut() { + let Some(height) = wallet_batch.core.synced_height else { + continue; + }; + if wallet_batch.core.synced_height_is_rewind { + continue; + } + let Some(info) = wm.get_wallet_info(wallet_id) else { + // No wallet holds this id any more: nothing may persist a cursor + // for it, and a same-id successor must not find one. + tracing::debug!( + wallet_id = %hex::encode(wallet_id), + projected = height, + "wallet-event adapter: dropping a sync-height advance for a removed wallet" + ); + wallet_batch.core.synced_height = None; + continue; + }; + // Projected in a rewind epoch that has since been superseded: the + // advance predates the rewind whatever its height, even once the + // rescan has climbed back past it. + if wallet_batch + .advance_epoch + .is_some_and(|epoch| epoch != info.rewind_barrier.epoch()) + { + tracing::debug!( + wallet_id = %hex::encode(wallet_id), + projected = height, + "wallet-event adapter: dropping a sync-height advance a rewind overtook" + ); + wallet_batch.core.synced_height = None; + continue; + } + let live = info.core_wallet.synced_height(); + if height > live { + tracing::debug!( + wallet_id = %hex::encode(wallet_id), + projected = height, + live, + "wallet-event adapter: dropping a sync-height advance projected before a rewind" + ); + wallet_batch.core.synced_height = None; + } + } +} + /// Drain `receiver`, project each [`WalletEvent`] into a /// [`CoreChangeSet`], and forward to the persister. Split out of /// [`spawn_wallet_event_adapter`] so the loop — not just the @@ -336,6 +710,7 @@ async fn run_wallet_event_adapter

( persister: Weak

, mut receiver: mpsc::UnboundedReceiver, sync_fault: Arc, + durable_cursors: DurableCursors, cancel: CancellationToken, ) where P: PlatformWalletPersistence + 'static, @@ -423,18 +798,9 @@ async fn run_wallet_event_adapter

( let mut batch: BTreeMap = BTreeMap::new(); let mut closed = false; - { - let wallet_id = event.wallet_id(); - // For events that need to consult per-wallet state, - // `build_core_changeset` takes a brief read lock on the manager. - let core = build_core_changeset(&wallet_manager, &event).await; - let asset_locks = reconstruct_asset_locks_for_event(&wallet_manager, &event).await; - let payments = sent_payment_verdicts(&wallet_manager, &event).await; - let entry = batch.entry(wallet_id).or_default(); - entry.core.merge(core); - entry.asset_locks.merge(asset_locks); - merge_payment_overlays(&mut entry.payments, payments); - } + // For events that need to consult per-wallet state, + // `build_core_changeset` takes a brief read lock on the manager. + project_event_into_batch(&wallet_manager, &durable_cursors, &event, &mut batch).await; // Fold in whatever else is already buffered. `try_recv` never waits, // so this drains the backlog at projection speed and stops as soon as @@ -443,18 +809,8 @@ async fn run_wallet_event_adapter

( while folded < ADAPTER_STORE_BATCH_LIMIT { match receiver.try_recv() { Ok(event) => { - let wallet_id = event.wallet_id(); - let core = build_core_changeset(&wallet_manager, &event).await; - let asset_locks = - reconstruct_asset_locks_for_event(&wallet_manager, &event).await; - let payments = sent_payment_verdicts(&wallet_manager, &event).await; - let entry = batch.entry(wallet_id).or_default(); - entry.core.merge(core); - entry.asset_locks.merge(asset_locks); - // Last-write-wins per `(owner, txid)`: a transaction swept - // and then reinstated inside one drain reaches the store as - // the verdict the drain ended on, never as two rows. - merge_payment_overlays(&mut entry.payments, payments); + project_event_into_batch(&wallet_manager, &durable_cursors, &event, &mut batch) + .await; folded += 1; } Err(TryRecvError::Empty) => break, @@ -505,6 +861,12 @@ async fn run_wallet_event_adapter

( // it again costs nothing — whereas the reverse error, omitting a wallet // whose store did run, would leave a watermark free to advance past // rows nobody can account for. + // Take the durable-cursor lock for the whole commit and re-check each + // watermark against the live cursor under it: a rewind that ran since + // this batch was projected must win (see [`DurableCursors`]). The + // guard rides into the commit thread and is released when it returns. + let mut cursors = Arc::clone(&durable_cursors.cursors).lock_owned().await; + drop_watermarks_above_the_live_cursor(&wallet_manager, &mut batch).await; let batch_wallet_ids: Vec = batch .iter() .filter(|(_, wallet_batch)| { @@ -523,6 +885,7 @@ async fn run_wallet_event_adapter

( let sync_fault_for_commit = Arc::clone(&sync_fault); let fault_for_commit = Arc::clone(&fault); let freeze_for_commit = Arc::clone(&freeze_logged); + let wallet_ids_for_commit = batch_wallet_ids.clone(); let committed = tokio::task::spawn_blocking(move || { // The lock is uncontended by construction — this task is the only // writer, and one drain commits at a time — so it never blocks; @@ -537,15 +900,18 @@ async fn run_wallet_event_adapter

( // `commit_batch` returns is lost when a later store in the same // batch panics, and the panic branch would then emit the one-shot // marker a second time for a freeze already announced. - let diag = commit_batch( + let diag = commit_batch_under_writer_lock( &*persister_for_commit, batch, folded, + &wallet_ids_for_commit, &mut fault, &sync_fault_for_commit, &freeze_for_commit, &mut settled, + &mut cursors, ); + drop(cursors); // Hand the claim back out: the next chunk of a cancellation drain // inherits it instead of racing a fresh upgrade against the owner's // release. A panicking `commit_batch` drops it instead, and the @@ -674,6 +1040,7 @@ async fn run_wallet_event_adapter

( /// means the rows never reached disk — the same condition that makes us /// fault the wallet — so counting it as persisted would make the trace /// contradict itself. +#[cfg_attr(not(test), allow(dead_code))] fn commit_batch

( persister: &P, batch: BTreeMap, @@ -702,6 +1069,80 @@ where diag } +/// [`commit_batch`] as the adapter runs it: holding the [`DurableCursors`] +/// writer lock, and leaving that lock's state true even when a store panics. +/// +/// Every cursor a store accepted is recorded before the lock can be +/// released, and a panic faults the wallets whose outcome it left unknown +/// before it propagates. Otherwise the unwind would release the lock with +/// an accepted height unrecorded and the fault latch still clear, and a +/// reconcile entering in that window would measure a new contact against a +/// durable cursor lower than the one the host actually holds +/// (dashpay/platform#4302 review). The panic is re-raised afterwards, so +/// the adapter's `JoinError` arm still logs it and freezes the same set. +#[allow(clippy::too_many_arguments)] +fn commit_batch_under_writer_lock

( + persister: &P, + batch: BTreeMap, + folded: usize, + batch_wallet_ids: &[WalletId], + fault: &mut AdapterFaultState, + sync_fault: &AtomicBool, + freeze_logged: &AtomicBool, + settled: &mut Vec, + cursors: &mut BTreeMap, +) -> BatchDiagnostics +where + P: PlatformWalletPersistence + ?Sized, +{ + let mut diag = BatchDiagnostics::new(folded, batch.len()); + // The epoch each wallet's advance was projected in, for the cursor + // entry its accepted height lands in. + let advance_epochs: BTreeMap> = batch + .iter() + .map(|(wallet_id, wallet_batch)| (*wallet_id, wallet_batch.advance_epoch)) + .collect(); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + for (wallet_id, wallet_batch) in batch { + commit_wallet( + persister, + wallet_id, + wallet_batch, + &mut diag, + fault, + sync_fault, + freeze_logged, + settled, + ); + } + })); + // What each host now holds, for the next writer to measure against — + // known only where the store is the commit. A buffering backend commits + // at a later `flush` that can still drop the round, so its accepted + // heights are not durable and are not recorded; it gets no backfill + // record either (see `reconcile_dashpay_rescan`), so nothing measures + // against them. + let durable = persister.store_commits_inline(); + for (wallet_id, height) in diag.persisted_by_wallet.iter().filter(|_| durable) { + cursors.insert( + *wallet_id, + DurableCursor { + height: *height, + advance_epoch: advance_epochs.get(wallet_id).copied().flatten(), + }, + ); + } + if let Err(panic) = unwound { + for wallet_id in batch_wallet_ids { + if !settled.contains(wallet_id) { + fault.fault_wallet(*wallet_id, sync_fault); + } + } + std::panic::resume_unwind(panic); + } + diag +} + /// Commit one wallet's folded changeset — the per-wallet unit of /// [`commit_batch`]. /// @@ -728,6 +1169,7 @@ fn commit_wallet

( mut core, asset_locks, payments, + advance_epoch: _, } = wallet_batch; { // Sent-payment verdicts reach a host only through the payment-overlay @@ -829,6 +1271,7 @@ fn commit_wallet

( // the adapter's own progress marker and holding it back would // re-drive work without making anything safer. core.synced_height = None; + core.synced_height_is_rewind = false; } let cs = PlatformWalletChangeSet { @@ -898,6 +1341,7 @@ fn commit_wallet

( Ok(()) => { if let Some(h) = offered_height { diag.record_persisted(h); + diag.persisted_by_wallet.insert(wallet_id, h); } } Err(e) => { @@ -1000,6 +1444,7 @@ fn fault_and_freeze( fn freeze_synced_height_if_faulted(core: &mut CoreChangeSet, persistence_faulted: bool) { if persistence_faulted { core.synced_height = None; + core.synced_height_is_rewind = false; } } @@ -1025,6 +1470,10 @@ struct WalletBatch { /// identity blob is instead patched row-by-row by the persister, inside /// its own write transaction — see the SQLite backend's `dashpay` module. payments: PaymentOverlay, + /// The [`RewindBarrier`] epoch of the advance in `core.synced_height`, + /// checked again at commit: an advance a rewind has since superseded is + /// stripped there. `None` while the batch carries no advance. + advance_epoch: Option, } /// Rebuild missing tracked asset locks from the records an event @@ -1188,6 +1637,8 @@ async fn build_core_changeset( wallet_manager: &Arc>>, event: &WalletEvent, ) -> CoreChangeSet { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + match event { WalletEvent::TransactionDetected { wallet_id, @@ -1425,10 +1876,38 @@ async fn build_core_changeset( ); CoreChangeSet::default() } - WalletEvent::SyncHeightAdvanced { height, .. } => CoreChangeSet { - synced_height: Some(*height), - ..CoreChangeSet::default() - }, + WalletEvent::SyncHeightAdvanced { wallet_id, height } => { + // A watermark queued before a rewind must not land after it. + // `reconcile_dashpay_rescan` lowers the in-memory cursor and + // stores the lowered value with its coverage record on its own + // round; an advance the engine emitted just before that rewind + // (still in this channel) would otherwise be persisted after + // it and put the durable cursor back above the range the rescan + // has yet to re-match — with coverage on disk vouching for it. + // The in-memory cursor is the wallet's truth at drain time: an + // event above it is stale, so its watermark is dropped and the + // rescan's own advances carry the cursor from the floor up. + let current = { + let wm = wallet_manager.read().await; + wm.get_wallet_info(wallet_id) + .map(|info| info.core_wallet.synced_height()) + }; + match current { + Some(current) if *height > current => { + tracing::debug!( + wallet_id = %hex::encode(wallet_id), + event_height = *height, + current, + "wallet-event adapter: dropping a sync-height advance queued before a rewind" + ); + CoreChangeSet::default() + } + _ => CoreChangeSet { + synced_height: Some(*height), + ..CoreChangeSet::default() + }, + } + } WalletEvent::ChainLockProcessed { chain_lock, .. } => { // The wallet has already promoted the matching records from // `InBlock` to `InChainLockedBlock` by the time this event @@ -3983,6 +4462,8 @@ mod contact_watch_only_projection_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), observed_input_conflicts: Default::default(), } } @@ -5057,6 +5538,8 @@ mod tests { /// for the block to be in effect rather than sleeping and hoping. blocked: Arc, capabilities: crate::changeset::PersistenceCapabilities, + /// When set, `store` only buffers: the backend commits at `flush`. + buffered: AtomicBool, } impl ProbePersister { @@ -5068,6 +5551,7 @@ mod tests { block_until: Mutex::new(None), blocked: Arc::new(AtomicBool::new(false)), capabilities: crate::changeset::PersistenceCapabilities::NONE, + buffered: AtomicBool::new(false), } } /// A probe that additionally attests `capabilities` — used by the @@ -5098,6 +5582,10 @@ mod tests { } impl PlatformWalletPersistence for ProbePersister { + fn store_commits_inline(&self) -> bool { + !self.buffered.load(Ordering::SeqCst) + } + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { self.capabilities } @@ -5173,6 +5661,573 @@ mod tests { WalletEvent::SyncHeightAdvanced { wallet_id, height } } + /// Register a fresh test wallet (cursor 0) in `manager`. The adapter + /// persists advances only for a wallet the manager holds + /// (dashpay/platform#4302 review), so a test feeding it bare events needs + /// the wallet present. + async fn insert_test_wallet( + manager: &Arc>>, + ) -> WalletId { + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + use key_wallet::test_utils::TestWalletContext; + + let ctx = TestWalletContext::new_random(); + let info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), + core_wallet: ctx.managed_wallet, + generation: Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), + }; + manager + .write() + .await + .insert_wallet(ctx.wallet, info) + .expect("insert wallet") + } + + /// Feed `tx` a sync-height advance the way the engine emits one: the + /// in-memory cursor moves and the wallet's barrier counts the emission. + async fn emit_advance( + manager: &Arc>>, + tx: &UnboundedSender, + wallet_id: WalletId, + height: u32, + ) { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + { + let mut wm = manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("registered wallet"); + info.update_synced_height(height); + } + tx.send(sync_height_event(wallet_id, height)).unwrap(); + } + + /// A watermark the engine queued BEFORE `reconcile_dashpay_rescan` + /// lowered the wallet's cursor must not be persisted after it: the + /// rescan round stores the lowered cursor with its coverage record, and + /// a stale advance landing afterwards would put the durable cursor back + /// above the range the rescan has yet to re-match, with coverage on + /// disk vouching for it (dashpay/platform#4302 review). The in-memory + /// cursor at drain time decides: an event above it is dropped, one at or + /// below it flows. + #[tokio::test] + async fn a_sync_height_advance_queued_before_a_rewind_is_dropped() { + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + use key_wallet::test_utils::TestWalletContext; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let ctx = TestWalletContext::new_random(); + let mut info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), + core_wallet: ctx.managed_wallet, + generation: Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), + }; + info.core_wallet.update_synced_height(1_000); + let manager = test_manager(); + let wallet_id = manager + .write() + .await + .insert_wallet(ctx.wallet, info) + .expect("insert wallet"); + + // In step with the engine: the advance the engine just applied. + let cs = super::build_core_changeset(&manager, &sync_height_event(wallet_id, 1_000)).await; + assert_eq!(cs.synced_height, Some(1_000)); + + // The rescan reconcile rewinds the wallet to 100 while that advance + // is still queued: it must not land. + manager + .write() + .await + .get_wallet_info_mut(&wallet_id) + .expect("info") + .core_wallet + .update_synced_height(100); + let stale = + super::build_core_changeset(&manager, &sync_height_event(wallet_id, 1_000)).await; + assert_eq!( + stale.synced_height, None, + "an advance above the rewound cursor was queued before the rewind" + ); + assert!( + crate::changeset::Merge::is_empty(&stale), + "nothing else rides a bare watermark" + ); + + // The rescan's own advances carry the cursor from the floor up. + let fresh = super::build_core_changeset(&manager, &sync_height_event(wallet_id, 100)).await; + assert_eq!(fresh.synced_height, Some(100)); + + // An unknown wallet has no cursor to compare against: unchanged. + let unknown = super::build_core_changeset(&manager, &sync_height_event([9u8; 32], 7)).await; + assert_eq!(unknown.synced_height, Some(7)); + } + + /// The commit-time re-check that closes the projection-to-commit window: + /// the adapter projects an advance to 1000, releases the manager, and a + /// rescan rewinds the wallet to 100 and stores that before the commit. + /// Under the durable-cursor lock the commit re-reads the live cursor and + /// drops the stale advance; a rewind-marked cursor and an advance at or + /// below the live cursor pass through (dashpay/platform#4302 review). + #[tokio::test] + async fn the_commit_drops_an_advance_projected_before_a_rewind() { + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + use key_wallet::test_utils::TestWalletContext; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let ctx = TestWalletContext::new_random(); + let mut info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), + core_wallet: ctx.managed_wallet, + generation: Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), + }; + info.core_wallet.update_synced_height(1_000); + let manager = test_manager(); + let wallet_id = manager + .write() + .await + .insert_wallet(ctx.wallet, info) + .expect("insert wallet"); + + // Projected while the wallet was at 1000. + let projected = + super::build_core_changeset(&manager, &sync_height_event(wallet_id, 1_000)).await; + assert_eq!(projected.synced_height, Some(1_000)); + + // The rescan rewinds before the commit. + manager + .write() + .await + .get_wallet_info_mut(&wallet_id) + .expect("info") + .core_wallet + .update_synced_height(100); + + let batch_with = |core: CoreChangeSet| { + let mut batch: BTreeMap = BTreeMap::new(); + batch.entry(wallet_id).or_default().core = core; + batch + }; + let mut stale = batch_with(projected); + super::drop_watermarks_above_the_live_cursor(&manager, &mut stale).await; + assert_eq!( + stale[&wallet_id].core.synced_height, None, + "stale advance dropped" + ); + + let mut in_step = batch_with(CoreChangeSet { + synced_height: Some(100), + ..CoreChangeSet::default() + }); + super::drop_watermarks_above_the_live_cursor(&manager, &mut in_step).await; + assert_eq!(in_step[&wallet_id].core.synced_height, Some(100)); + + let mut rewind = batch_with(CoreChangeSet { + synced_height: Some(500), + synced_height_is_rewind: true, + ..CoreChangeSet::default() + }); + super::drop_watermarks_above_the_live_cursor(&manager, &mut rewind).await; + assert_eq!( + rewind[&wallet_id].core.synced_height, + Some(500), + "a rewind is never second-guessed" + ); + } + + /// A wallet at `synced_height` with the manager's lossless persistence + /// receiver installed, so advances go through the engine's real + /// emission path (and the [`RewindBarrier`] hook counting it). + async fn engine_wallet( + synced_height: u32, + ) -> ( + Arc>>, + tokio::sync::mpsc::UnboundedReceiver, + WalletId, + ) { + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + use key_wallet::test_utils::TestWalletContext; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let ctx = TestWalletContext::new_random(); + let mut info = PlatformWalletInfo { + observed_input_conflicts: Default::default(), + core_wallet: ctx.managed_wallet, + generation: Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), + }; + info.core_wallet.update_synced_height(synced_height); + let manager = test_manager(); + let receiver = manager + .write() + .await + .take_persistence_receiver() + .expect("persistence receiver"); + let wallet_id = manager + .write() + .await + .insert_wallet(ctx.wallet, info) + .expect("insert wallet"); + (manager, receiver, wallet_id) + } + + /// The scan cursor as the engine advances it: in memory, and emitted. + async fn engine_advance( + manager: &Arc>>, + wallet_id: WalletId, + height: u32, + ) { + use key_wallet_manager::WalletInterface; + manager + .write() + .await + .update_wallet_synced_height(&wallet_id, height); + } + + /// The scan cursor as `reconcile_dashpay_rescan` lowers it: in memory + /// only, arming the barrier under the same write lock. + async fn rewind( + manager: &Arc>>, + wallet_id: WalletId, + floor: u32, + ) { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let mut wm = manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.core_wallet.update_synced_height(floor); + info.rewind_barrier.arm(); + } + + async fn project( + manager: &Arc>>, + durable: &super::DurableCursors, + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, + count: usize, + ) -> BTreeMap { + let mut batch = BTreeMap::new(); + for _ in 0..count { + let event = receiver.try_recv().expect("queued event"); + super::project_event_into_batch(manager, durable, &event, &mut batch).await; + } + batch + } + + /// dashpay/platform#4302 review: an advance the engine emitted before a + /// rewind, still queued when the rescan has climbed back past it, looks + /// current by height — the live cursor is above it again — yet storing + /// it would put the durable cursor above transaction rows the rescan + /// emitted behind it. The barrier drops it by emission order instead. + #[tokio::test] + async fn an_advance_queued_before_a_rewind_is_dropped_after_the_rescan_climbs_past_it() { + let (manager, mut receiver, wallet_id) = engine_wallet(0).await; + let durable = super::DurableCursors::default(); + engine_advance(&manager, wallet_id, 1_000).await; // pre-rewind, still queued + rewind(&manager, wallet_id, 100).await; + engine_advance(&manager, wallet_id, 500).await; + engine_advance(&manager, wallet_id, 1_000).await; // the rescan is back at 1000 + + // The adapter drains the pre-rewind advance on its own. + let mut stale = project(&manager, &durable, &mut receiver, 1).await; + assert_eq!(stale[&wallet_id].core.synced_height, None); + super::drop_watermarks_above_the_live_cursor(&manager, &mut stale).await; + assert_eq!(stale[&wallet_id].core.synced_height, None); + + // The rescan's own advances flow, behind the rows they imply. + let mut fresh = project(&manager, &durable, &mut receiver, 2).await; + super::drop_watermarks_above_the_live_cursor(&manager, &mut fresh).await; + assert_eq!(fresh[&wallet_id].core.synced_height, Some(1_000)); + } + + /// The same race with the advance already projected when the rewind + /// runs: by commit the rescan has climbed back past it, so the live + /// cursor no longer exposes it, and the superseded epoch does. + #[tokio::test] + async fn the_commit_drops_an_advance_a_rewind_overtook_after_the_rescan_climbs_past_it() { + let (manager, mut receiver, wallet_id) = engine_wallet(0).await; + let durable = super::DurableCursors::default(); + engine_advance(&manager, wallet_id, 1_000).await; + let mut projected = project(&manager, &durable, &mut receiver, 1).await; + assert_eq!(projected[&wallet_id].core.synced_height, Some(1_000)); + + rewind(&manager, wallet_id, 100).await; + engine_advance(&manager, wallet_id, 1_200).await; + super::drop_watermarks_above_the_live_cursor(&manager, &mut projected).await; + assert_eq!( + projected[&wallet_id].core.synced_height, None, + "projected before the rewind: stripped although the live cursor is above it" + ); + + // The rescan's advance, in the next batch, is stored. + let mut next = project(&manager, &durable, &mut receiver, 1).await; + super::drop_watermarks_above_the_live_cursor(&manager, &mut next).await; + assert_eq!(next[&wallet_id].core.synced_height, Some(1_200)); + } + + /// A wallet removed and registered again under the same id shares the + /// adapter with its predecessor, which can still hold that predecessor's + /// advances — queued, or already projected. While nothing holds the id + /// every one of them is dropped, at projection and at commit, and + /// counted off the retired barrier; the successor inherits what is still + /// queued, so those drop as stale and the projected ones fail the epoch + /// check at commit, even once the new scan has climbed above them — while + /// the successor's own advances flow (dashpay/platform#4302 review). + #[tokio::test] + async fn a_same_id_successor_drops_its_predecessors_queued_and_projected_advances() { + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let (manager, mut receiver, wallet_id) = engine_wallet(0).await; + let durable = super::DurableCursors::default(); + engine_advance(&manager, wallet_id, 700).await; + let mut projected = project(&manager, &durable, &mut receiver, 1).await; // in a batch + engine_advance(&manager, wallet_id, 1_000).await; // still queued + engine_advance(&manager, wallet_id, 1_100).await; // still queued + + // Removed: the barrier is retired under the id. + let (wallet, predecessor_generation) = { + let (wallet, info) = manager + .write() + .await + .remove_wallet(&wallet_id) + .expect("remove"); + let generation = WalletInfoInterface::account_generation(&info); + durable + .retired() + .insert(wallet_id, info.rewind_barrier.retire(generation)); + (wallet, generation) + }; + assert_eq!(durable.retired()[&wallet_id].in_flight, 2); + + // While nothing holds the id: the projected advance is stripped at + // commit, and a queued one is dropped at projection and counted off. + super::drop_watermarks_above_the_live_cursor(&manager, &mut projected).await; + assert_eq!( + projected[&wallet_id].core.synced_height, None, + "nothing may persist a cursor for a removed wallet" + ); + let absent = project(&manager, &durable, &mut receiver, 1).await; + assert_eq!(absent[&wallet_id].core.synced_height, None); + assert_eq!( + durable.retired()[&wallet_id].in_flight, + 1, + "the dropped advance no longer counts as queued" + ); + + // Registered again under the same id. + let retired = durable.retired().remove(&wallet_id).expect("retired"); + let mut successor = PlatformWalletInfo { + observed_input_conflicts: Default::default(), + core_wallet: ManagedWalletInfo::from_wallet(&wallet, 0), + generation: Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), + }; + successor.rewind_barrier.inherit(retired); + assert!( + WalletInfoInterface::account_generation(&successor) > predecessor_generation, + "a batch reconciled against the predecessor's accounts cannot match the successor" + ); + assert_eq!( + manager + .write() + .await + .insert_wallet(wallet, successor) + .expect("reinsert"), + wallet_id + ); + engine_advance(&manager, wallet_id, 1_200).await; // the successor's own scan + + let mut queued = project(&manager, &durable, &mut receiver, 1).await; + assert_eq!( + queued[&wallet_id].core.synced_height, None, + "the predecessor's last queued advance drops as stale" + ); + super::drop_watermarks_above_the_live_cursor(&manager, &mut queued).await; + let mut own = project(&manager, &durable, &mut receiver, 1).await; + super::drop_watermarks_above_the_live_cursor(&manager, &mut own).await; + assert_eq!( + own[&wallet_id].core.synced_height, + Some(1_200), + "the successor's own advance is not mistaken for a stale one" + ); + } + + /// A store panicking mid-batch must not release the writer lock with the + /// lock's state behind reality: wallet A's accepted cursor is recorded, + /// and wallet B — whose outcome the panic left unknown — is faulted, + /// both before the panic propagates to the adapter's async `JoinError` + /// arm, so the next writer to take the lock already sees them + /// (dashpay/platform#4302 review). + #[test] + fn a_panicking_commit_records_accepted_cursors_and_faults_before_the_lock_is_released() { + let (obs_tx, _obs_rx) = tokio::sync::mpsc::unbounded_channel(); + let persister = ProbePersister::new(obs_tx); + let a: WalletId = [1u8; 32]; + let b: WalletId = [2u8; 32]; + persister.panic_next(b); + let watermark = |height| super::WalletBatch { + core: CoreChangeSet { + synced_height: Some(height), + ..CoreChangeSet::default() + }, + ..Default::default() + }; + let mut batch = BTreeMap::new(); + batch.insert( + a, + super::WalletBatch { + advance_epoch: Some(3), + ..watermark(1_000) + }, + ); + batch.insert(b, watermark(500)); + + let mut fault = super::AdapterFaultState::default(); + let sync_fault = AtomicBool::new(false); + let freeze_logged = AtomicBool::new(false); + let mut settled = Vec::new(); + let mut cursors: BTreeMap = BTreeMap::from([ + (a, super::DurableCursor::at(100)), + (b, super::DurableCursor::at(100)), + ]); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + super::commit_batch_under_writer_lock( + &persister, + batch, + 2, + &[a, b], + &mut fault, + &sync_fault, + &freeze_logged, + &mut settled, + &mut cursors, + ) + })); + assert!(unwound.is_err(), "the panic still propagates"); + assert_eq!( + cursors.get(&a), + Some(&super::DurableCursor { + height: 1_000, + advance_epoch: Some(3), + }), + "A's accepted cursor is recorded with the epoch its advance was projected in" + ); + assert_eq!( + cursors.get(&b).map(|cursor| cursor.height), + Some(100), + "B's store never returned" + ); + assert!( + fault.is_faulted(&b), + "B is faulted before the lock is released" + ); + assert!(!fault.is_faulted(&a), "A settled"); + assert!(sync_fault.load(Ordering::Relaxed)); + } + + /// A backend whose `store` only buffers commits at a later `flush`, which + /// can still fail and drop the round. An accepted store there is not a + /// durable cursor, so the adapter records nothing for the next writer to + /// measure against (dashpay/platform#4302 review). + #[test] + fn a_buffering_backends_accepted_advance_is_not_recorded_as_durable() { + let (obs_tx, _obs_rx) = tokio::sync::mpsc::unbounded_channel(); + let persister = ProbePersister::new(obs_tx); + persister.buffered.store(true, Ordering::SeqCst); + let a: WalletId = [1u8; 32]; + let mut batch = BTreeMap::new(); + batch.insert( + a, + super::WalletBatch { + core: CoreChangeSet { + synced_height: Some(1_000), + ..CoreChangeSet::default() + }, + advance_epoch: Some(3), + ..Default::default() + }, + ); + let mut fault = super::AdapterFaultState::default(); + let sync_fault = AtomicBool::new(false); + let freeze_logged = AtomicBool::new(false); + let mut settled = Vec::new(); + let mut cursors: BTreeMap = + BTreeMap::from([(a, super::DurableCursor::at(100))]); + let diag = super::commit_batch_under_writer_lock( + &persister, + batch, + 1, + &[a], + &mut fault, + &sync_fault, + &freeze_logged, + &mut settled, + &mut cursors, + ); + assert_eq!( + diag.persisted_by_wallet.get(&a).copied(), + Some(1_000), + "the store accepted the advance" + ); + assert_eq!( + cursors.get(&a), + Some(&super::DurableCursor::at(100)), + "an accepted store that is not a commit is not a durable cursor" + ); + } + + /// A batch that holds a pre-rewind advance and then the rescan's first + /// one ends on the rescan's height: the newer epoch replaces the older + /// advance rather than max-merging with it, which would keep the higher, + /// pre-rewind height. + #[tokio::test] + async fn a_batch_spanning_a_rewind_keeps_the_rescans_height_not_the_higher_stale_one() { + let (manager, mut receiver, wallet_id) = engine_wallet(0).await; + let durable = super::DurableCursors::default(); + engine_advance(&manager, wallet_id, 1_000).await; + let first = receiver.try_recv().expect("pre-rewind advance"); + let mut batch = BTreeMap::new(); + super::project_event_into_batch(&manager, &durable, &first, &mut batch).await; + + rewind(&manager, wallet_id, 100).await; + engine_advance(&manager, wallet_id, 400).await; + let second = receiver.try_recv().expect("rescan advance"); + super::project_event_into_batch(&manager, &durable, &second, &mut batch).await; + assert_eq!(batch[&wallet_id].core.synced_height, Some(400)); + + super::drop_watermarks_above_the_live_cursor(&manager, &mut batch).await; + assert_eq!(batch[&wallet_id].core.synced_height, Some(400)); + } + /// A record-bearing (non-watermark) event: carries /// `last_processed_height` but no `synced_height`, so it survives the /// freeze untouched. Used both as a sentinel to prove loop progress and @@ -5199,12 +6254,13 @@ mod tests { /// the watermark forever. #[tokio::test] async fn lossless_burst_never_freezes_and_watermark_reaches_tip() { + let manager = test_manager(); const BURST: u32 = 3000; // >> the old broadcast ring (1000) - let wallet_id = [7u8; 32]; + let wallet_id = insert_test_wallet(&manager).await; let (tx, rx) = unbounded_channel::(); // Pre-buffer the whole burst; an unbounded mpsc cannot drop it. for h in 1..=BURST { - tx.send(sync_height_event(wallet_id, h)).unwrap(); + emit_advance(&manager, &tx, wallet_id, h).await; } let (obs_tx, mut obs_rx) = unbounded_channel(); @@ -5212,10 +6268,11 @@ mod tests { let sync_fault = Arc::new(AtomicBool::new(false)); let cancel = CancellationToken::new(); let handle = tokio::spawn(run_wallet_event_adapter( - test_manager(), + Arc::clone(&manager), Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -5259,10 +6316,11 @@ mod tests { /// such guarantee; see the `Drop` rustdoc on `PlatformWalletManager`. #[tokio::test] async fn cancellation_commits_the_events_already_buffered() { - let wallet_id = [11u8; 32]; + let manager = test_manager(); + let wallet_id = insert_test_wallet(&manager).await; let (tx, rx) = unbounded_channel::(); - tx.send(sync_height_event(wallet_id, 41)).unwrap(); - tx.send(sync_height_event(wallet_id, 42)).unwrap(); + emit_advance(&manager, &tx, wallet_id, 41).await; + emit_advance(&manager, &tx, wallet_id, 42).await; let (obs_tx, mut obs_rx) = unbounded_channel(); let persister = Arc::new(ProbePersister::new(obs_tx)); @@ -5272,10 +6330,11 @@ mod tests { cancel.cancel(); run_wallet_event_adapter( - test_manager(), + Arc::clone(&manager), Arc::downgrade(&persister), rx, Arc::new(AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), cancel, ) .await; @@ -5309,14 +6368,15 @@ mod tests { /// interleaving that exposes it. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_cancelled_drain_holds_the_persister_until_its_backlog_is_committed() { + let manager = test_manager(); use std::time::{Duration, Instant}; - let wallet_id = [0x55u8; 32]; + let wallet_id = insert_test_wallet(&manager).await; // One past the limit: the tail event cannot ride the first chunk. let backlog = ADAPTER_STORE_BATCH_LIMIT as u32 + 1; let (tx, rx) = unbounded_channel::(); for height in 1..=backlog { - tx.send(sync_height_event(wallet_id, height)).unwrap(); + emit_advance(&manager, &tx, wallet_id, height).await; } let (obs_tx, mut obs_rx) = unbounded_channel(); @@ -5326,10 +6386,11 @@ mod tests { let cancel = CancellationToken::new(); cancel.cancel(); let handle = tokio::spawn(run_wallet_event_adapter( - test_manager(), + Arc::clone(&manager), Arc::downgrade(&persister), rx, Arc::new(AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), cancel, )); @@ -5391,6 +6452,7 @@ mod tests { Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -5445,6 +6507,7 @@ mod tests { Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -5493,6 +6556,7 @@ mod tests { Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -5524,11 +6588,12 @@ mod tests { /// awaiting — making the batch boundary deterministic. #[tokio::test] async fn buffered_events_fold_into_one_store_per_wallet() { - let wallet_id = [11u8; 32]; + let manager = test_manager(); + let wallet_id = insert_test_wallet(&manager).await; // Comfortably larger than the burst, so nothing is dropped. let (tx, rx) = unbounded_channel::(); for h in 1..=5u32 { - tx.send(sync_height_event(wallet_id, h)).unwrap(); + emit_advance(&manager, &tx, wallet_id, h).await; } let (obs_tx, mut obs_rx) = unbounded_channel(); @@ -5536,10 +6601,11 @@ mod tests { let sync_fault = Arc::new(AtomicBool::new(false)); let cancel = CancellationToken::new(); let handle = tokio::spawn(run_wallet_event_adapter( - test_manager(), + manager, Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -5574,24 +6640,26 @@ mod tests { /// other's id. #[tokio::test] async fn batch_folds_per_wallet_not_across_wallets() { - let wallet_a = [1u8; 32]; - let wallet_b = [2u8; 32]; + let manager = test_manager(); + let wallet_a = insert_test_wallet(&manager).await; + let wallet_b = insert_test_wallet(&manager).await; let (tx, rx) = unbounded_channel::(); // Interleaved on purpose. - tx.send(sync_height_event(wallet_a, 10)).unwrap(); - tx.send(sync_height_event(wallet_b, 20)).unwrap(); - tx.send(sync_height_event(wallet_a, 11)).unwrap(); - tx.send(sync_height_event(wallet_b, 21)).unwrap(); + emit_advance(&manager, &tx, wallet_a, 10).await; + emit_advance(&manager, &tx, wallet_b, 20).await; + emit_advance(&manager, &tx, wallet_a, 11).await; + emit_advance(&manager, &tx, wallet_b, 21).await; let (obs_tx, mut obs_rx) = unbounded_channel(); let persister = Arc::new(ProbePersister::new(obs_tx)); let sync_fault = Arc::new(AtomicBool::new(false)); let cancel = CancellationToken::new(); let handle = tokio::spawn(run_wallet_event_adapter( - test_manager(), + manager, Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -5639,6 +6707,7 @@ mod tests { Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -5726,6 +6795,7 @@ mod tests { Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -5787,6 +6857,7 @@ mod tests { Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -5852,12 +6923,20 @@ mod tests { /// faulted every wallet in the drain. #[tokio::test] async fn a_panicking_commit_spares_the_wallets_it_already_stored() { + let manager = test_manager(); // `BTreeMap` order decides who is committed first, so the ids are // chosen to put the healthy wallet ahead of the panicking one. - let healthy = [0x11u8; 32]; - let doomed = [0x22u8; 32]; + let healthy = insert_test_wallet(&manager).await; + let doomed = insert_test_wallet(&manager).await; // Sorts after `doomed`, so the commit unwinds before it is reached. - let unreached = [0x33u8; 32]; + let unreached = insert_test_wallet(&manager).await; + // `BTreeMap` order decides who is committed first: sort the random ids + // so the roles keep that order. + let (healthy, doomed, unreached) = { + let mut ids = [healthy, doomed, unreached]; + ids.sort(); + (ids[0], ids[1], ids[2]) + }; let (tx, rx) = unbounded_channel::(); let (obs_tx, mut obs_rx) = unbounded_channel(); @@ -5866,10 +6945,11 @@ mod tests { let sync_fault = Arc::new(AtomicBool::new(false)); let cancel = CancellationToken::new(); let handle = tokio::spawn(run_wallet_event_adapter( - test_manager(), + Arc::clone(&manager), Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -5893,7 +6973,7 @@ mod tests { // The healthy wallet's watermark must still advance: its store // returned, so its rows are accounted for. - tx.send(sync_height_event(healthy, 900)).unwrap(); + emit_advance(&manager, &tx, healthy, 900).await; // Bounded: on a regression the healthy wallet is frozen, its // watermark-only changeset collapses to nothing, and no store is // observed at all — an unbounded `recv` would hang CI instead of @@ -5911,7 +6991,7 @@ mod tests { // The wallet whose store panicked must be frozen. tx.send(block_processed_event(doomed, 60)).unwrap(); - tx.send(sync_height_event(doomed, 900)).unwrap(); + emit_advance(&manager, &tx, doomed, 900).await; let after_doomed = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) .await .expect("a faulted wallet must still persist its rows") @@ -5928,7 +7008,7 @@ mod tests { // wallet's. A handler that faults only the direct casualty leaves this // one free to advance past rows that never landed. tx.send(block_processed_event(unreached, 60)).unwrap(); - tx.send(sync_height_event(unreached, 900)).unwrap(); + emit_advance(&manager, &tx, unreached, 900).await; let after_unreached = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) .await @@ -5971,6 +7051,7 @@ mod tests { Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -6059,6 +7140,7 @@ mod tests { Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -6133,6 +7215,7 @@ mod tests { Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); @@ -6168,7 +7251,8 @@ mod tests { /// any other. #[tokio::test] async fn sweep_with_declared_capability_does_not_freeze() { - let wallet_id = [22u8; 32]; + let manager = test_manager(); + let wallet_id = insert_test_wallet(&manager).await; let (tx, rx) = unbounded_channel::(); let (obs_tx, mut obs_rx) = unbounded_channel(); let persister = Arc::new(ProbePersister::with_capabilities( @@ -6178,17 +7262,18 @@ mod tests { let sync_fault = Arc::new(AtomicBool::new(false)); let cancel = CancellationToken::new(); let handle = tokio::spawn(run_wallet_event_adapter( - test_manager(), + Arc::clone(&manager), Arc::downgrade(&persister), rx, Arc::clone(&sync_fault), + crate::changeset::DurableCursors::default(), cancel.clone(), )); tx.send(swept_event(wallet_id, 0x61, 0x62)).unwrap(); // A watermark-bearing event right behind it, folded or not — either // way it must reach the store untouched while the capability holds. - tx.send(sync_height_event(wallet_id, 700)).unwrap(); + emit_advance(&manager, &tx, wallet_id, 700).await; let mut last_synced = None; // Drain until a store carries the watermark. Each receive is bounded: @@ -6834,6 +7919,7 @@ mod tests { core: CoreChangeSet::default(), asset_locks, payments: super::PaymentOverlay::default(), + advance_epoch: None, }, ); commit_batch( @@ -6894,6 +7980,7 @@ mod tests { core, asset_locks: AssetLockChangeSet::default(), payments: super::PaymentOverlay::default(), + advance_epoch: None, }, ); batch @@ -6935,6 +8022,7 @@ mod tests { core: watermark_with_rows(700, 700), asset_locks: AssetLockChangeSet::default(), payments: one_verdict_overlay(PaymentStatus::Failed), + advance_epoch: None, }, ); let diag = commit_batch( @@ -6987,6 +8075,7 @@ mod tests { core: watermark_with_rows(700, 700), asset_locks: AssetLockChangeSet::default(), payments: one_verdict_overlay(PaymentStatus::Failed), + advance_epoch: None, }, ); let diag = commit_batch( @@ -7039,6 +8128,7 @@ mod tests { core: CoreChangeSet::default(), asset_locks: AssetLockChangeSet::default(), payments: one_verdict_overlay(PaymentStatus::Failed), + advance_epoch: None, }, ); commit_batch( @@ -7081,6 +8171,7 @@ mod tests { core: CoreChangeSet::default(), asset_locks: AssetLockChangeSet::default(), payments: one_verdict_overlay(PaymentStatus::Confirmed), + advance_epoch: None, }, ); commit_batch( @@ -7503,6 +8594,7 @@ mod tests { Arc::downgrade(&persister), rx, Arc::new(AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), CancellationToken::new(), )); handle.await.expect("the adapter exits on a closed channel"); @@ -7900,6 +8992,8 @@ mod utxo_credit_verdict_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), observed_input_conflicts: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); @@ -7994,6 +9088,8 @@ mod utxo_credit_verdict_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), observed_input_conflicts: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); diff --git a/packages/rs-platform-wallet/src/changeset/dashpay_backfill.rs b/packages/rs-platform-wallet/src/changeset/dashpay_backfill.rs new file mode 100644 index 00000000000..fdaa8f2facb --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/dashpay_backfill.rs @@ -0,0 +1,711 @@ +//! Durable record of the DashPay coreHeight backfill (DIP-15 §12.6). +//! +//! `reconcile_dashpay_rescan` lowers the wallet's SPV `synced_height` to the +//! earliest contact-request height among receival contacts whose addresses +//! were not yet watched when those blocks were first scanned, so the filter +//! scanner re-matches that range with the contact's addresses in the set. +//! The guard that stops the recurring sweep from re-lowering the cursor every +//! pass used to live only in memory (`DashPayState::rescan_triggered`), while +//! the cursor it lowers is durable: the host persists every +//! `SyncHeightAdvanced` the engine emits as the rescan climbs. A fresh process +//! therefore restored a cursor *inside* the climb, saw no guard, and rewound +//! again — one full re-walk per launch, forever (dashpay/platform#4302). +//! +//! [`DashPayBackfillRecord`] is the guard's durable half. It travels with the +//! wallet's own changeset round (the one that also carries the lowered +//! cursor), crosses the FFI as its own size-negotiated persistence slot and a +//! wallet-restore field, and comes back through +//! [`ClientWalletStartState::dashpay_backfill`]. +//! +//! [`ClientWalletStartState::dashpay_backfill`]: crate::changeset::ClientWalletStartState::dashpay_backfill + +use dpp::prelude::Identifier; + +use super::{PersistenceCapabilities, PlatformWalletPersistence}; + +/// One receival account the backfill covers, and the height it covers it +/// from. +/// +/// Keyed on the whole receival-account key — `(owner, contact, +/// account_index)` — not just the relationship: a second receiving account +/// for the same pair derives a different xpub, so its scripts were never +/// watched by a scan that covered the first, and it must not inherit that +/// coverage. +/// +/// `covered_from` is the contact's scan checkpoint at the moment its coverage +/// was recorded — the earliest height whose block the scanner is guaranteed to +/// test against this contact's addresses. It is stored per contact rather than +/// as one wallet-wide floor because a contact's checkpoint can *drop* after it +/// was covered (the reciprocal request turns out to be older than ours, or a +/// rotation falls back to wallet birth): a wallet-wide floor would then read +/// as "still covered" for blocks the scan never tested with that contact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DashPayBackfillCoveredContact { + /// Our identity — the owner of the receival account. + pub owner: Identifier, + /// The contact whose payments the receival account collects. + pub contact: Identifier, + /// The receival account's index (`DashpayAccountKey::index`). + pub account_index: u32, + /// Lowest height the scan is guaranteed to have tested (or to test, as it + /// climbs) with this contact's addresses watched. + pub covered_from: u32, +} + +impl DashPayBackfillCoveredContact { + /// The receival-account key the record is sorted and looked up by. + pub fn key(&self) -> (Identifier, Identifier, u32) { + (self.owner, self.contact, self.account_index) + } +} + +/// Per-wallet durable record of the DashPay coreHeight backfill. +/// +/// # What a record claims +/// +/// For every entry in [`covered`](Self::covered): from the moment the record +/// was written, the contact's receival addresses are in the filter match set, +/// and the durable `synced_height` was lowered to at most that contact's +/// `covered_from` in the very same persistence round. From then on the +/// persisted cursor tracks the scanner's real progress *with the contact +/// watched* — the pinned engine refuses to certify a batch scanned at the old +/// checkpoint once the cursor sits below it (contiguity guard, +/// dashpay/rust-dashcore#649) — so on any later launch every block in +/// `[covered_from, synced_height]` has been tested against that contact, and +/// everything above the cursor is about to be. Such a contact needs no second +/// rewind unless its checkpoint has since dropped *below* `covered_from`. +/// +/// # What it does not claim +/// +/// Nothing about a receival account the record does not list. A contact whose +/// account was registered after the record was written (or on this device by +/// a path that never recorded it) is re-armed exactly as before, because the +/// scan that produced this record never watched it. Any ambiguity falls back +/// to re-running the backfill: slow, never lossy. +/// +/// # Completion +/// +/// [`floor`](Self::floor) and [`rewound_from`](Self::rewound_from) describe +/// the backfill's extent: the lowest height it rewound to and the cursor it +/// rewound from. The backfill is complete once `synced_height` has climbed +/// back past `rewound_from` ([`is_complete`](Self::is_complete)); while it is +/// still climbing ([`is_pending`](Self::is_pending)) a restart resumes the +/// climb from the persisted cursor rather than restarting it from the floor. +/// Completion is never *marked* — it is read off the cursor, which is the +/// only thing that actually moved. Marking it at trigger time would have made +/// an interrupted backfill look finished; not marking it costs nothing, +/// because the per-contact `covered_from` entries, not the completion state, +/// are what suppress a second rewind. +/// +/// A later rewind folds into the existing record ([`record_pass`](Self::record_pass)): +/// the floor can only go down, `rewound_from` can only go up, and a contact's +/// entry is replaced by the new checkpoint it was covered from. +/// +/// Every way in — [`from_entries`](Self::from_entries), [`from_parts`](Self::from_parts) +/// and serde deserialization — canonicalizes [`covered`](Self::covered), so a +/// restored record never carries an order or a duplicate the lookups would +/// misread. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(from = "DashPayBackfillRecordSerde"))] +pub struct DashPayBackfillRecord { + /// Lowest height a backfill rewound the cursor to. Meaningless while + /// [`has_extent`](Self::has_extent) is false. + pub floor: u32, + /// Highest cursor a backfill rewound from — the height the scan has to + /// climb back to for the backfill to be complete. `0` while no pass has + /// rewound anything ([`has_extent`](Self::has_extent)). + pub rewound_from: u32, + /// Receival accounts the backfill covers, sorted by `(owner, contact, + /// account_index)`, at most one entry per account. Private so nothing + /// outside this type can break that order, which `covered_from`'s binary + /// search relies on; read it through [`covered`](Self::covered). + covered: Vec, + /// In-memory only, never persisted or crossed over the FFI: a cursor a + /// previous record round still owes the host because that round's + /// store failed. The next successful record round carries it as the + /// round's `synced_height`, so coverage never reaches disk over a + /// durable cursor that is still at a height the in-memory rewind left + /// behind. `None` when nothing is owed. + #[cfg_attr(feature = "serde", serde(skip))] + pub unpersisted_cursor: Option, + /// In-memory only: the `RewindBarrier` epoch the latest contribution to + /// [`unpersisted_cursor`](Self::unpersisted_cursor) was recorded in. The + /// debt is paid once the host accepts a scan advance from this epoch or + /// later — see [`owe_cursor`](Self::owe_cursor). + #[cfg_attr(feature = "serde", serde(skip))] + pub unpersisted_cursor_epoch: Option, + /// In-memory only, like [`unpersisted_cursor`](Self::unpersisted_cursor): + /// the `(floor, rewound_from)` a rewind whose record round failed to + /// store still has to contribute. The retry folds it back in, so the + /// stored record keeps the backfill's real extent instead of the empty + /// one a retry with no rewind of its own would produce. + #[cfg_attr(feature = "serde", serde(skip))] + pub unpersisted_extent: Option<(u32, u32)>, + /// In-memory only: the backend permanently refused a record round this + /// session (a non-transient store failure). Coverage is then kept for + /// the session only, beside the debt, and no further record round is + /// attempted: retrying a rejection that will keep failing would only + /// leave every later sweep rewinding to the same contacts again. A + /// relaunch starts with it clear, and with no durable coverage for those + /// contacts re-runs their backfill. + #[cfg_attr(feature = "serde", serde(skip))] + pub store_refused: bool, +} + +/// Whether `persister` can hold a durable [`DashPayBackfillRecord`]. +/// +/// The backend has to store the record at all +/// ([`stores_dashpay_backfill`](PlatformWalletPersistence::stores_dashpay_backfill)), +/// and the receival accounts the coverage is about +/// ([`ACCOUNT_ADDRESS_POOLS`](PersistenceCapabilities::ACCOUNT_ADDRESS_POOLS)): +/// coverage restored without its account vouches for scripts nothing watches. +/// The record vouches for the cursor written in the same round, so both +/// halves have to land together or not at all, and be on disk once `store` +/// returns. That takes a backend whose `store` is the commit +/// ([`store_commits_inline`](PlatformWalletPersistence::store_commits_inline)) +/// AND that attests [`PersistenceCapabilities::ATOMIC_CHANGESETS`]: an +/// inline but non-atomic backend could keep the coverage after rejecting +/// the cursor, and a buffering one could still drop the round at `flush`. +/// Any other backend gets session-only coverage — the pre-record guard. +pub fn persists_backfill_coverage

(persister: &P) -> bool +where + P: PlatformWalletPersistence + ?Sized, +{ + persister.stores_dashpay_backfill() + && persister.store_commits_inline() + && persister.persistence_capabilities().contains( + PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::ACCOUNT_ADDRESS_POOLS), + ) +} + +/// The persisted fields of a [`DashPayBackfillRecord`] as serde reads them, +/// before [`DashPayBackfillRecord::from_entries`] canonicalizes the cover set. +#[cfg(feature = "serde")] +#[derive(serde::Deserialize)] +struct DashPayBackfillRecordSerde { + floor: u32, + rewound_from: u32, + covered: Vec, +} + +#[cfg(feature = "serde")] +impl From for DashPayBackfillRecord { + fn from(record: DashPayBackfillRecordSerde) -> Self { + Self::from_entries(record.floor, record.rewound_from, record.covered) + } +} + +impl DashPayBackfillRecord { + /// A restored record from its persisted parts, with the cover set put in + /// canonical form whatever order or duplicates the source held: sorted by + /// receival-account key, one entry per account. A duplicated account + /// keeps its HIGHEST `covered_from` — an ambiguous record must fail + /// closed, and the higher height is the one that re-arms more + /// checkpoints. Every decoder of a stored record goes through here, so + /// [`covered_from`](Self::covered_from)'s binary search never lands on a + /// lower duplicate. + pub fn from_entries( + floor: u32, + rewound_from: u32, + mut covered: Vec, + ) -> Self { + covered.sort_by_key(|entry| (entry.key(), std::cmp::Reverse(entry.covered_from))); + covered.dedup_by(|a, b| a.key() == b.key()); + Self { + floor, + rewound_from, + covered, + unpersisted_cursor: None, + unpersisted_cursor_epoch: None, + unpersisted_extent: None, + store_refused: false, + } + } + + /// Owe the host `cursor`, lowered in memory during rewind epoch `epoch`. + /// Folds into any debt already owed: the lower cursor, and the later + /// epoch — the debt is paid only by an advance the host accepts after the + /// latest lowering. + pub fn owe_cursor(&mut self, cursor: u32, epoch: u64) { + self.unpersisted_cursor = Some( + self.unpersisted_cursor + .map_or(cursor, |owed| owed.min(cursor)), + ); + self.unpersisted_cursor_epoch = Some( + self.unpersisted_cursor_epoch + .map_or(epoch, |owed| owed.max(epoch)), + ); + } + + /// Forget the owed cursor once the host has accepted a scan advance from + /// the epoch it was owed in or later (`host_advance_epoch`): the host + /// then tracks the scan that restarted from the lowered cursor, and + /// writing the owed height would only drag it back below that scan. + /// Returns whether a debt was dropped. + pub fn settle_owed_cursor(&mut self, host_advance_epoch: Option) -> bool { + let paid = self.unpersisted_cursor.is_some() + && host_advance_epoch + .zip(self.unpersisted_cursor_epoch) + .is_some_and(|(host, owed)| host >= owed); + if paid { + self.unpersisted_cursor = None; + self.unpersisted_cursor_epoch = None; + } + paid + } + + /// The covered receival accounts, sorted by `(owner, contact, + /// account_index)`, at most one entry per account. + pub fn covered(&self) -> &[DashPayBackfillCoveredContact] { + &self.covered + } + + /// Whether no backfill has ever been recorded for this wallet. + pub fn is_empty(&self) -> bool { + self.covered.is_empty() + } + + /// The height this record covers the receival account `(owner, contact, + /// account_index)` from, if it covers it at all. + pub fn covered_from( + &self, + owner: &Identifier, + contact: &Identifier, + account_index: u32, + ) -> Option { + self.covered + .binary_search_by(|entry| entry.key().cmp(&(*owner, *contact, account_index))) + .ok() + .map(|index| self.covered[index].covered_from) + } + + /// Whether the receival account `(owner, contact, account_index)` is + /// covered at `checkpoint`: the record lists it and its checkpoint has not + /// dropped below the height it was covered from. + pub fn covers( + &self, + owner: &Identifier, + contact: &Identifier, + account_index: u32, + checkpoint: u32, + ) -> bool { + self.covered_from(owner, contact, account_index) + .is_some_and(|covered_from| checkpoint >= covered_from) + } + + /// Whether a backfill ever rewound the cursor, i.e. whether `floor` / + /// `rewound_from` describe anything. A record whose every contact was + /// forward-covered has entries but no extent, and `rewound_from == 0` + /// cannot come from a real rewind (a checkpoint is never below 0). + pub fn has_extent(&self) -> bool { + self.rewound_from > 0 + } + + /// The backfill is still climbing back to the cursor it rewound from. + pub fn is_pending(&self, synced_height: u32) -> bool { + self.has_extent() && synced_height < self.rewound_from + } + + /// The scan has climbed back past the cursor the backfill rewound from, + /// so every block in `[floor, rewound_from]` has been re-matched. + pub fn is_complete(&self, synced_height: u32) -> bool { + self.has_extent() && synced_height >= self.rewound_from + } + + /// Fold one reconcile pass into the record. + /// + /// `synced_height_before` is the cursor before the pass rewound it (the + /// height the scan must climb back to); `floor` is where the pass rewound + /// to, or `None` when every contact it handled was already covered by the + /// forward scan. Each `(owner, contact, account_index, covered_from)` entry + /// replaces any existing entry for that receival account. + /// + /// Returns `true` when the record changed. + pub fn record_pass( + &mut self, + synced_height_before: u32, + floor: Option, + entries: impl IntoIterator, + ) -> bool { + let mut changed = false; + for (owner, contact, account_index, covered_from) in entries { + let entry = DashPayBackfillCoveredContact { + owner, + contact, + account_index, + covered_from, + }; + match self + .covered + .binary_search_by(|existing| existing.key().cmp(&entry.key())) + { + Ok(index) => { + if self.covered[index] != entry { + self.covered[index] = entry; + changed = true; + } + } + Err(index) => { + self.covered.insert(index, entry); + changed = true; + } + } + } + // The extent describes real rewinds only. A pass that rewound nothing + // (every contact forward-covered, or already covered) says nothing + // about how far a backfill reached, so it must not invent one from + // the cursor it happened to run at — on a record that had no extent + // yet that would make `floor == rewound_from == cursor`, which reads + // complete at any later cursor and measures nothing. + let Some(pass_floor) = floor else { + return changed; + }; + // A first rewind takes the pass's extent as-is; a later one can only + // widen it — deeper floor, higher climb target. "First" is read off + // the extent, never off the cover set: `retain` can empty the set + // while the extent of the backfill those entries rode is still + // being climbed. + let (new_floor, new_rewound_from) = if !self.has_extent() { + (pass_floor, synced_height_before) + } else { + ( + self.floor.min(pass_floor), + self.rewound_from.max(synced_height_before), + ) + }; + if new_floor != self.floor || new_rewound_from != self.rewound_from { + self.floor = new_floor; + self.rewound_from = new_rewound_from; + changed = true; + } + changed + } + + /// Drop every entry for which `keep(owner, contact, account_index)` is + /// false — used to forget receival accounts that no longer exist. Returns + /// `true` when the record changed. + pub fn retain(&mut self, mut keep: impl FnMut(&Identifier, &Identifier, u32) -> bool) -> bool { + let before = self.covered.len(); + self.covered + .retain(|entry| keep(&entry.owner, &entry.contact, entry.account_index)); + before != self.covered.len() + } + + /// Wire size of one [`DashPayBackfillCoveredContact`] in + /// [`covered_bytes`](Self::covered_bytes): owner ‖ contact ‖ + /// `account_index` ‖ `covered_from` (both little-endian). + pub const COVERED_ENTRY_LEN: usize = 32 + 32 + 4 + 4; + + /// Flat encoding of [`covered`](Self::covered) for hosts that store the + /// list as one opaque blob: [`COVERED_ENTRY_LEN`](Self::COVERED_ENTRY_LEN) + /// bytes per entry, in record order. + pub fn covered_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(self.covered.len() * Self::COVERED_ENTRY_LEN); + for entry in &self.covered { + out.extend_from_slice(&entry.owner.to_buffer()); + out.extend_from_slice(&entry.contact.to_buffer()); + out.extend_from_slice(&entry.account_index.to_le_bytes()); + out.extend_from_slice(&entry.covered_from.to_le_bytes()); + } + out + } + + /// Inverse of [`covered_bytes`](Self::covered_bytes). + /// + /// Returns `None` when `bytes` is not a whole number of entries: a + /// truncated or foreign blob must not be read as a shorter cover set, + /// because a contact missing from the set is simply re-armed while a + /// contact wrongly *present* in it is never backfilled again. The caller + /// treats `None` as "no record" — the safe direction. + pub fn from_parts(floor: u32, rewound_from: u32, covered_bytes: &[u8]) -> Option { + if !covered_bytes.len().is_multiple_of(Self::COVERED_ENTRY_LEN) { + return None; + } + let (chunks, _) = covered_bytes.as_chunks::<{ Self::COVERED_ENTRY_LEN }>(); + let covered: Vec = chunks + .iter() + .map(|chunk| { + let mut owner = [0u8; 32]; + let mut contact = [0u8; 32]; + let mut account_index = [0u8; 4]; + let mut height = [0u8; 4]; + owner.copy_from_slice(&chunk[..32]); + contact.copy_from_slice(&chunk[32..64]); + account_index.copy_from_slice(&chunk[64..68]); + height.copy_from_slice(&chunk[68..72]); + DashPayBackfillCoveredContact { + owner: Identifier::from(owner), + contact: Identifier::from(contact), + account_index: u32::from_le_bytes(account_index), + covered_from: u32::from_le_bytes(height), + } + }) + .collect(); + Some(Self::from_entries(floor, rewound_from, covered)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(byte: u8) -> Identifier { + Identifier::from([byte; 32]) + } + + #[test] + fn a_first_pass_sets_the_extent_and_a_later_one_only_widens_it() { + let mut record = DashPayBackfillRecord::default(); + assert!(record.is_empty()); + assert!(!record.is_pending(0)); + assert!(!record.is_complete(u32::MAX)); + + assert!(record.record_pass(1_000, Some(100), [(id(1), id(2), 0, 100)])); + assert_eq!((record.floor, record.rewound_from), (100, 1_000)); + assert!(record.is_pending(999)); + assert!(record.is_complete(1_000)); + + // A shallower rewind while climbing neither raises the floor nor + // lowers the climb target. + assert!(record.record_pass(400, Some(300), [(id(1), id(3), 0, 300)])); + assert_eq!((record.floor, record.rewound_from), (100, 1_000)); + + // A deeper rewind after completion lowers the floor and raises the + // climb target to the cursor it rewound from. + assert!(record.record_pass(2_000, Some(50), [(id(1), id(4), 0, 50)])); + assert_eq!((record.floor, record.rewound_from), (50, 2_000)); + + // A forward-covered pass (no rewind) records the contact without + // touching the extent it cannot widen. + assert!(record.record_pass(1_500, None, [(id(1), id(5), 0, 1_700)])); + assert_eq!((record.floor, record.rewound_from), (50, 2_000)); + assert_eq!(record.covered_from(&id(1), &id(5), 0), Some(1_700)); + + // An unchanged replay is reported as such. + assert!(!record.record_pass(1_500, None, [(id(1), id(5), 0, 1_700)])); + } + + /// A pass that rewound nothing must not invent an extent from the cursor + /// it ran at: on the device that produced `floor == rewound_from == + /// 1251329` — the cursor of a later pass — which read complete at any + /// later cursor. The first real rewind then sets the extent. + #[test] + fn a_forward_only_pass_on_an_empty_record_leaves_no_extent() { + let mut record = DashPayBackfillRecord::default(); + assert!(record.record_pass(1_251_329, None, [(id(1), id(2), 0, 1_300_000)])); + assert!(!record.has_extent()); + assert!(!record.is_pending(1_000)); + assert!(!record.is_complete(2_000_000)); + assert!(record.covers(&id(1), &id(2), 0, 1_300_000)); + + assert!(record.record_pass(1_560_731, Some(1_226_329), [(id(1), id(3), 0, 1_226_329)])); + assert!(record.has_extent()); + assert_eq!((record.floor, record.rewound_from), (1_226_329, 1_560_731)); + assert!(record.is_pending(1_251_329)); + assert!(record.is_complete(1_560_731)); + } + + #[test] + fn coverage_is_per_contact_and_keyed_on_the_recorded_checkpoint() { + let mut record = DashPayBackfillRecord::default(); + record.record_pass( + 1_000, + Some(100), + [(id(1), id(2), 0, 100), (id(1), id(3), 0, 300)], + ); + + assert!(record.covers(&id(1), &id(2), 0, 100)); + assert!(record.covers(&id(1), &id(2), 0, 250)); + assert!( + !record.covers(&id(1), &id(2), 0, 99), + "a checkpoint below the covered height re-arms" + ); + assert!( + !record.covers(&id(1), &id(9), 0, 500), + "an unlisted contact is never covered" + ); + assert!( + !record.covers(&id(7), &id(2), 0, 500), + "coverage is per owner, too" + ); + + // Re-recording a pair replaces its entry. + assert!(record.record_pass(500, Some(40), [(id(1), id(2), 0, 40)])); + assert_eq!(record.covered_from(&id(1), &id(2), 0), Some(40)); + assert_eq!(record.covered.len(), 2); + } + + #[test] + fn the_flat_encoding_round_trips_and_refuses_a_torn_blob() { + let mut record = DashPayBackfillRecord::default(); + record.record_pass( + 1_000, + Some(100), + [(id(9), id(2), 0, 100), (id(1), id(3), 0, 300)], + ); + let bytes = record.covered_bytes(); + assert_eq!(bytes.len(), 2 * DashPayBackfillRecord::COVERED_ENTRY_LEN); + + let restored = DashPayBackfillRecord::from_parts(record.floor, record.rewound_from, &bytes) + .expect("a whole blob decodes"); + assert_eq!(restored, record); + + assert!( + DashPayBackfillRecord::from_parts(100, 1_000, &bytes[..bytes.len() - 1]).is_none(), + "a torn blob must read as no record, never as a shorter cover set" + ); + let empty = DashPayBackfillRecord::from_parts(0, 0, &[]).expect("empty decodes"); + assert!(empty.is_empty()); + } + + #[test] + fn a_host_stored_blob_is_canonicalised_on_the_way_in() { + let entry = |owner: u8, contact: u8, from: u32| { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[owner; 32]); + bytes.extend_from_slice(&[contact; 32]); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&from.to_le_bytes()); + bytes + }; + let mut bytes = entry(5, 6, 700); + bytes.extend(entry(1, 2, 100)); + bytes.extend(entry(5, 6, 900)); // duplicate pair + let record = DashPayBackfillRecord::from_parts(100, 1_000, &bytes).expect("decodes"); + assert_eq!(record.covered.len(), 2); + assert_eq!(record.covered[0].owner, id(1)); + assert_eq!( + record.covered_from(&id(5), &id(6), 0), + Some(900), + "a duplicated account keeps its highest height, re-arming more" + ); + assert!(!record.covers(&id(5), &id(6), 0, 800)); + } + + /// Serde is a restore path too: a record serialized with its cover set + /// out of order and holding a conflicting duplicate must come back in + /// canonical form, the duplicate at its highest height — not left for + /// the binary search to land on the lower one and suppress a rewind. + #[cfg(feature = "serde")] + #[test] + fn a_serde_restored_record_is_canonicalised_on_the_way_in() { + let entry = |owner: u8, contact: u8, covered_from: u32| DashPayBackfillCoveredContact { + owner: id(owner), + contact: id(contact), + account_index: 0, + covered_from, + }; + let stored = DashPayBackfillRecord { + floor: 100, + rewound_from: 1_000, + covered: vec![entry(5, 6, 700), entry(1, 2, 100), entry(5, 6, 900)], + ..Default::default() + }; + let json = serde_json::to_string(&stored).expect("serializes"); + let restored: DashPayBackfillRecord = serde_json::from_str(&json).expect("deserializes"); + assert_eq!( + restored, + DashPayBackfillRecord::from_entries( + 100, + 1_000, + vec![entry(1, 2, 100), entry(5, 6, 900)] + ) + ); + assert_eq!( + restored.covered, + vec![entry(1, 2, 100), entry(5, 6, 900)], + "sorted by account, one entry each" + ); + assert!( + !restored.covers(&id(5), &id(6), 0, 800), + "the duplicate keeps its highest height, re-arming a checkpoint below it" + ); + } + + #[test] + fn retain_forgets_contacts_and_reports_whether_anything_went() { + let mut record = DashPayBackfillRecord::default(); + record.record_pass( + 1_000, + Some(100), + [(id(1), id(2), 0, 100), (id(1), id(3), 0, 300)], + ); + assert!(!record.retain(|_, _, _| true)); + assert!(record.retain(|_, contact, _| *contact != id(3))); + assert_eq!(record.covered_from(&id(1), &id(3), 0), None); + assert_eq!(record.covered_from(&id(1), &id(2), 0), Some(100)); + } + + /// A debt folds to the lower cursor and the later epoch, and is settled + /// only by a host advance from that epoch or later. + #[test] + fn an_owed_cursor_is_settled_only_by_a_host_advance_from_its_epoch_or_later() { + let mut record = DashPayBackfillRecord::default(); + assert!(!record.settle_owed_cursor(Some(9)), "nothing owed"); + record.owe_cursor(500, 2); + record.owe_cursor(300, 1); + assert_eq!(record.unpersisted_cursor, Some(300)); + assert_eq!(record.unpersisted_cursor_epoch, Some(2)); + + assert!( + !record.settle_owed_cursor(None), + "a seeded host cursor proves nothing" + ); + assert!( + !record.settle_owed_cursor(Some(1)), + "an advance from before the latest lowering" + ); + assert_eq!(record.unpersisted_cursor, Some(300)); + assert!(record.settle_owed_cursor(Some(2))); + assert_eq!(record.unpersisted_cursor, None); + assert_eq!(record.unpersisted_cursor_epoch, None); + } + + /// `retain` can empty the cover set while the backfill those entries rode + /// is still climbing. The next rewind must widen that extent, not replace + /// it: replacing `(100, 1000)` with `(200, 500)` would read complete at 500 + /// with `[500, 1000]` never re-matched. + #[test] + fn pruning_the_last_covered_contact_keeps_the_extent_the_next_rewind_widens() { + let mut record = DashPayBackfillRecord::default(); + record.record_pass(1_000, Some(100), [(id(1), id(2), 0, 100)]); + assert!(record.retain(|_, _, _| false)); + assert!(record.is_empty()); + assert_eq!((record.floor, record.rewound_from), (100, 1_000)); + + assert!(record.record_pass(500, Some(200), [(id(1), id(3), 0, 200)])); + assert_eq!((record.floor, record.rewound_from), (100, 1_000)); + assert!(record.is_pending(500)); + assert!(!record.is_complete(999)); + } + + /// A second receiving account for the same relationship derives its own + /// xpub, so a scan that watched the first never tested the second's + /// scripts: coverage is per receival account, not per pair. + #[test] + fn coverage_is_per_receival_account_not_per_relationship() { + let mut record = DashPayBackfillRecord::default(); + record.record_pass(1_000, Some(100), [(id(1), id(2), 0, 100)]); + assert!(record.covers(&id(1), &id(2), 0, 100)); + assert!( + !record.covers(&id(1), &id(2), 1, 100), + "account 1 never inherits account 0's coverage" + ); + + record.record_pass(1_000, Some(100), [(id(1), id(2), 1, 100)]); + assert_eq!(record.covered.len(), 2); + let restored = DashPayBackfillRecord::from_parts( + record.floor, + record.rewound_from, + &record.covered_bytes(), + ) + .expect("decodes"); + assert_eq!(restored, record); + assert!(record.retain(|_, _, account_index| account_index == 0)); + assert!(!record.covers(&id(1), &id(2), 1, 100)); + assert!(record.covers(&id(1), &id(2), 0, 100)); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 4125b8df568..f246d3f6056 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -13,6 +13,7 @@ pub mod changeset; pub mod client_start_state; pub mod client_wallet_start_state; pub mod core_bridge; +pub mod dashpay_backfill; pub mod identity_manager_start_state; pub mod identity_scan_state; pub mod merge; @@ -45,7 +46,13 @@ pub use changeset::{ }; pub use client_start_state::ClientStartState; pub use client_wallet_start_state::ClientWalletStartState; -pub use core_bridge::spawn_wallet_event_adapter; +pub use core_bridge::{ + spawn_wallet_event_adapter, spawn_wallet_event_adapter_with_durable_cursors, DurableCursor, + DurableCursorState, DurableCursors, RetiredBarrier, +}; +pub use dashpay_backfill::{ + persists_backfill_coverage, DashPayBackfillCoveredContact, DashPayBackfillRecord, +}; pub use identity_manager_start_state::IdentityManagerStartState; pub use identity_scan_state::IdentityScanStateEntry; pub use merge::Merge; @@ -58,5 +65,6 @@ pub use shielded_changeset_disabled::ShieldedChangeSet; #[cfg(feature = "shielded")] pub use shielded_sync_start_state::{ShieldedSubwalletStartState, ShieldedSyncStartState}; pub use traits::{ - ListedCoreTxid, PersistenceError, PersistenceErrorKind, PlatformWalletPersistence, + persists_outbound_account_markers, ListedCoreTxid, PersistenceError, PersistenceErrorKind, + PlatformWalletPersistence, }; diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index e1538a26263..11e5d2c58a7 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -172,6 +172,32 @@ impl PersistenceError { } } +/// Whether a contact's outbound-account marker +/// (`EstablishedContact::external_account_reference`) can be kept on +/// `persister` and trusted after a restart. +/// +/// A restored marker suppresses the cold-start rebuild of the contact's +/// outbound account, so it may only reach disk where the account it names is +/// durably written with it: a backend whose `store` is the commit +/// ([`store_commits_inline`](PlatformWalletPersistence::store_commits_inline)), +/// whose rounds are all-or-nothing +/// ([`ATOMIC_CHANGESETS`](PersistenceCapabilities::ATOMIC_CHANGESETS)), and +/// that persists account registrations and their address pools +/// ([`ACCOUNT_ADDRESS_POOLS`](PersistenceCapabilities::ACCOUNT_ADDRESS_POOLS)). +/// On any other backend the marker is kept in memory only: contact rows are +/// written without it and a restored one is not trusted, so each launch +/// rebuilds the account once — the behaviour before the marker was persisted. +pub fn persists_outbound_account_markers

(persister: &P) -> bool +where + P: PlatformWalletPersistence + ?Sized, +{ + persister.store_commits_inline() + && persister.persistence_capabilities().contains( + PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::ACCOUNT_ADDRESS_POOLS), + ) +} + /// Storage backend for [`PlatformWalletChangeSet`] deltas. /// /// The persister persists what the changeset carries — nothing more, @@ -271,6 +297,24 @@ pub trait PlatformWalletPersistence: Send + Sync { false } + /// Whether this backend persists the + /// [`DashPayBackfillRecord`](crate::changeset::DashPayBackfillRecord) a + /// changeset carries, and can replace one it holds. + /// + /// Durable backfill coverage also needs inline, atomic rounds (see + /// [`persists_backfill_coverage`](crate::changeset::persists_backfill_coverage)). + /// A backend that answers `false` gets session-only coverage, and a + /// record it still hands back on load can only be invalidated through + /// [`store`](Self::store) — so a backend that cannot write the record + /// must fail such a store rather than acknowledge it. + /// + /// **Fail-closed:** the default is `false`. A backend that ignores the + /// record keeps the pre-record behaviour: the backfill re-runs on every + /// launch, slow but never lossy. + fn stores_dashpay_backfill(&self) -> bool { + false + } + /// Compatibility summary for older invitation callers. It is true when the /// backend attests atomic changesets plus durably persisted invitation rows /// and asset-lock funding indices. This does not attest restart hydration; diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8ba719674d0..7f171b1ab98 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -38,6 +38,16 @@ pub enum PlatformWalletError { #[error("failed to persist wallet changeset: {0}")] PersisterStore(#[source] crate::changeset::PersistenceError), + /// A persister `flush` failed: the commit of rounds a backend whose + /// `store` only buffers was still holding. It keeps the flush's own + /// classification, which means something different from a store's: + /// `Transient` says the buffer survived and another `flush` may commit + /// it, `Constraint` that the buffered data was rejected, and `Fatal` that + /// the buffered rounds were discarded. See [`Self::PersisterLoad`] for why + /// the typed cause is carried. + #[error("failed to commit buffered wallet changesets: {0}")] + PersisterFlush(#[source] crate::changeset::PersistenceError), + /// Restoring persisted platform-address state into a freshly registered /// wallet failed. Boxed to break the recursion; the inner variant and its /// `#[source]` chain survive intact. @@ -1032,6 +1042,16 @@ impl PlatformWalletError { Self::PersisterStore(source) } + /// A persister `flush` failed. Unlike + /// [`from_store_failure`](Self::from_store_failure) the classification is + /// kept as the backend reported it: a transient flush failure is retried + /// with another `flush`, which the store-side reissue attestation says + /// nothing about. See [`Self::from_load_failure`] for why no blanket + /// conversion exists. + pub fn from_flush_failure(source: crate::changeset::PersistenceError) -> Self { + Self::PersisterFlush(source) + } + /// Restoring persisted platform-address state failed. Boxes `source`, so /// callers never write `Box::new`. pub fn from_restore_failure(source: PlatformWalletError) -> Self { diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index c4ff3dfb6b2..6fea92fd06b 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -837,13 +837,14 @@ impl PlatformWalletManager

{ /// /// `synced_height` may regress here: that is safe because it is the /// filter-scan checkpoint, decoupled from the monotonic - /// `last_processed_height`, and every persisted sync cursor is - /// monotonic-max guarded (see `reconcile_dashpay_rescan`'s note), so a - /// transient rewind cannot corrupt state or persist a lower cursor. + /// `last_processed_height`, so a transient rewind cannot corrupt state. /// /// The rewound checkpoint lives only in the in-memory `WalletManager`; this - /// call does not persist it. If the process dies before the rescan finishes, - /// the host must issue this request again after restart. Requires SPV + /// call does not persist it — though the host will persist every height + /// the rescan then climbs through, so a relaunch resumes the climb from + /// wherever it got to rather than from the high-water. If the process dies + /// before the rescan finishes and the host wants it restarted from + /// `from_height`, it must issue this request again after restart. Requires SPV /// running for an immediate effect; otherwise it takes effect when SPV next /// starts in the same process and its filter loop first ticks. /// @@ -866,6 +867,15 @@ impl PlatformWalletManager

{ return true; } info.core_wallet.update_synced_height(from_height); + // Advances the engine emitted before this reset must not be stored + // after it. + info.rewind_barrier.arm(); + // Not persisted here, so remember it as owed: the next DashPay + // backfill record round carries it as that round's cursor, and no + // coverage reaches disk beside a durable cursor this reset retracted + // in memory only (dashpay/platform#4302 review). + let epoch = info.rewind_barrier.epoch(); + info.dashpay_backfill.owe_cursor(from_height, epoch); tracing::info!( wallet_id = %hex::encode(wallet_id), from_height, @@ -1946,6 +1956,8 @@ mod txo_inventory_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), observed_input_conflicts: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 1bbd0330172..1b612536087 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -13,6 +13,10 @@ use crate::wallet::PlatformWallet; use std::time::Duration; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; +use dpp::prelude::Identifier; +use key_wallet::account::derivation::AccountDerivation; +use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::transaction_checking::transaction_context::TransactionContext; use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; @@ -122,13 +126,27 @@ impl PlatformWalletManager

{ Vec::new(); let mut load_error: Option = None; - 'load: for (expected_wallet_id, wallet_state) in wallets { + // Each wallet's start state, and whether it was re-read under the + // durable-cursor lock that `held` still holds (see the publication + // step below). + let mut pending: std::collections::VecDeque<(WalletId, ClientWalletStartState, bool)> = + wallets + .into_iter() + .map(|(wallet_id, state)| (wallet_id, state, false)) + .collect(); + // The durable-cursor lock, kept from a re-read to the publication of + // the wallet processed from it. Nothing between the two takes the + // lock, and it is released before the rollback below. + let mut held = None; + + 'load: while let Some((expected_wallet_id, wallet_state, reread)) = pending.pop_front() { let ClientWalletStartState { mut wallet, mut wallet_info, identity_manager, unused_asset_locks, unconfirmed_outgoing_txs, + dashpay_backfill, } = wallet_state; // Replay the sends the host still holds as unconfirmed, before @@ -222,14 +240,55 @@ impl PlatformWalletManager

{ core_balance.immature(), core_balance.locked(), ); - let platform_info = PlatformWalletInfo { + let mut platform_info = PlatformWalletInfo { observed_input_conflicts: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), tracked_asset_locks, dpns_name_states: std::collections::BTreeMap::new(), + dashpay_backfill, + rewind_barrier: Default::default(), }; + // A restored outbound-account marker says the contact's external + // account was built from its current xpub, which suppresses the + // startup rebuild. It must not vouch for a pool the host kept + // across a rotation: the host replaces the account's xpub but may + // keep the old pool's addresses, and `send_payment` hands out a + // pooled address before deriving a new one. So any external pool + // holding an address its current xpub does not derive loses its + // contact's marker, and the first contact sweep rebuilds it from + // the current xpub, as every cold start did before the marker + // was persisted (dashpay/platform#4302 review). + // + // A backend that cannot keep markers durably never had one written + // by this process, and one it hands back may name an account it + // did not keep: none is trusted, so the sweep rebuilds each + // outbound account once, as before the marker was persisted. Its + // pools are then not validated at all: no result could keep a + // marker that is cleared regardless. + if crate::changeset::persists_outbound_account_markers(&*self.persister) { + let distrusted = + distrust_markers_of_foreign_external_pools(&wallet, &mut platform_info); + if distrusted > 0 { + tracing::warn!( + wallet_id = %hex::encode(expected_wallet_id), + contacts = distrusted, + "load: restored DashPay external pools hold addresses their current \ + xpub does not derive; their accounts will be rebuilt" + ); + } + } else { + let cleared = clear_restored_outbound_markers(&mut platform_info); + if cleared > 0 { + tracing::info!( + wallet_id = %hex::encode(expected_wallet_id), + contacts = cleared, + "load: this backend does not keep outbound-account markers durably; \ + restored markers are not trusted" + ); + } + } // Seed the double-spend screen's session memory from the // freshly restored state: it closes the race where SPV's // chainlock dispatcher promotion-evicts a restored spender @@ -238,6 +297,10 @@ impl PlatformWalletManager

{ &platform_info, ); + // The cursor the host just handed back is the durable one; it + // seeds the shared record of it as the wallet is published below. + let loaded_cursor = platform_info.core_wallet.metadata.synced_height; + if wallet_id != expected_wallet_id { load_error = Some(PlatformWalletError::WalletCreation(format!( "Persisted wallet id {} does not match recomputed id {}", @@ -265,11 +328,109 @@ impl PlatformWalletManager

{ // // The existence check and the insert share one write-lock scope // so a concurrent loader can't slip between them (TOCTOU). + // + // The durable cursor is seeded in that same scope, under the + // cursor lock taken first (its order: cursor lock, then manager). + // The insert makes the wallet visible to the scanner and the + // event adapter; seeding after it would let an adapter commit or + // a reconcile record a newer durable height first, and the seed + // would then overwrite it with the stale loaded one. An entry a + // removed same-id predecessor left behind is what the host holds + // now — a commit of its may have been accepted after the start + // state was read — so it wins over the loaded cursor, and a + // loaded cursor below it is owed to the host like any in-memory + // reset, in this wallet's inherited epoch, so no coverage is + // recorded beside the host's higher cursor without a reset + // (dashpay/platform#4302 review). + // + // The same predecessor can also have changed the host's DashPay + // backfill record after the start state was read: stored coverage + // for a receival account this snapshot does not hold, or + // invalidated coverage this snapshot still carries. So when an + // entry shows a same-id wallet was published in this process, the + // record this wallet carries replaces the host's before the wallet + // is published, paired with the loaded cursor when that is below + // the host's (`settle_published_backfill`). The existence check + // comes first, so a wallet still registered under this id is never + // overwritten, and every publisher holds the cursor lock, so none + // appears before the insert. + // + // A record is not all such a predecessor can have changed. It can + // also have replaced a receival account's xpub under the same + // `(owner, contact, index)` key and stored coverage scanned with + // the new one. Publishing this snapshot would restore the old + // xpub beside that key, and the scan it then runs would vouch for + // scripts the host's account does not derive. Coverage carries no + // xpub to check against, so the snapshot itself is replaced: under + // the lock, where no publisher or adapter commit can move the + // host, the start state is read again, and this wallet is + // processed from that read and published WITHOUT releasing the + // lock in between. A same-id wallet can only appear by being + // published, which takes this lock, so nothing can register, + // rotate an account and be removed between the read and the + // publication. No token stands in for that guarantee: a cursor + // entry does not move on account writes or same-id lifecycle + // changes, so it cannot prove a read is still current + // (dashpay/platform#4302 review). { - let mut wm = self.wallet_manager.write().await; - if wm.get_wallet(&wallet_id).is_some() { + let mut durable_cursors = match held.take() { + Some(guard) => guard, + None => self.durable_cursors.lock().await, + }; + if self + .wallet_manager + .read() + .await + .get_wallet(&wallet_id) + .is_some() + { continue 'load; } + if !reread && durable_cursors.contains_key(&wallet_id) { + let persister = Arc::clone(&self.persister); + let current = match run_blocking_load(move || persister.load()).await { + Ok(mut state) => state.wallets.remove(&expected_wallet_id), + Err(e) => { + load_error = Some(PlatformWalletError::from_load_failure(e)); + break 'load; + } + }; + match current { + Some(state) => { + pending.push_front((expected_wallet_id, state, true)); + held = Some(durable_cursors); + } + None => tracing::warn!( + wallet_id = %hex::encode(expected_wallet_id), + "load: the wallet is no longer on the host; not publishing its \ + stale snapshot" + ), + } + continue 'load; + } + // Coverage for a receival account this wallet did not restore + // vouches for scripts nothing will watch; it is dropped here, + // and from the host before the wallet is published, exactly + // as registration does (dashpay/platform#4302 review). + let dropped = super::retain_coverage_for_held_accounts( + &mut platform_info.dashpay_backfill, + &platform_info.core_wallet, + ); + match self.settle_published_backfill( + &mut durable_cursors, + wallet_id, + std::mem::take(&mut platform_info.dashpay_backfill), + dropped, + loaded_cursor, + loaded_cursor, + ) { + Ok(record) => platform_info.dashpay_backfill = record, + Err(e) => { + load_error = Some(e); + break 'load; + } + } + let mut wm = self.wallet_manager.write().await; if let Err(e) = wm.insert_wallet(wallet, platform_info) { load_error = Some(PlatformWalletError::WalletCreation(format!( "Failed to register persisted wallet in WalletManager: {}", @@ -277,6 +438,19 @@ impl PlatformWalletManager

{ ))); break 'load; } + let host_cursor = durable_cursors + .get(&wallet_id) + .map_or(loaded_cursor, |entry| entry.height); + durable_cursors + .entry(wallet_id) + .or_insert(crate::changeset::DurableCursor::at(loaded_cursor)); + self.inherit_rewind_barrier(&mut wm, &wallet_id); + if loaded_cursor < host_cursor { + if let Some(info) = wm.get_wallet_info_mut(&wallet_id) { + let epoch = info.rewind_barrier.epoch(); + info.dashpay_backfill.owe_cursor(loaded_cursor, epoch); + } + } } inserted_in_manager.push(wallet_id); @@ -340,6 +514,8 @@ impl PlatformWalletManager

{ Arc::clone(&self.lock_notify), Arc::clone(&persister_dyn), broadcaster, + Arc::clone(&self.sync_fault), + Arc::clone(&self.durable_cursors), ); // Initialize the platform-address provider. If the snapshot @@ -402,6 +578,9 @@ impl PlatformWalletManager

{ } } } + // A `break` between a re-read and its publication leaves the lock + // held; the rollback must not run under it. + drop(held); if let Some(err) = load_error { // Walk back every wallet committed in this call so the @@ -465,7 +644,7 @@ impl PlatformWalletManager

{ } continue; } - if let Err(e) = wm.remove_wallet(id) { + if let Err(e) = self.remove_from_wallet_manager(&mut wm, id) { tracing::warn!( wallet_id = %hex::encode(id), error = %e, @@ -718,6 +897,7 @@ mod idempotent_load_tests { identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), unconfirmed_outgoing_txs: self.pending.clone(), + dashpay_backfill: Default::default(), }, ); Ok(ClientStartState { @@ -784,6 +964,7 @@ mod idempotent_load_tests { identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), + dashpay_backfill: Default::default(), }, ); Ok(ClientStartState { @@ -823,6 +1004,7 @@ mod idempotent_load_tests { identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), + dashpay_backfill: Default::default(), }; let mut wallets = BTreeMap::new(); wallets.insert(self.wallet.compute_wallet_id(), entry()); @@ -1283,6 +1465,125 @@ mod idempotent_load_tests { } } +/// Clear the outbound-account marker of every established contact whose +/// restored DashPay external pool holds an address the account's current +/// xpub (on `wallet`) does not derive at that index, so the contact sweep +/// rebuilds the account instead of trusting the pool. Returns how many +/// markers were cleared. +fn distrust_markers_of_foreign_external_pools( + wallet: &key_wallet::Wallet, + info: &mut PlatformWalletInfo, +) -> usize { + let foreign: Vec<_> = info + .core_wallet + .accounts + .dashpay_external_accounts + .iter() + // Only a marker can be distrusted, so a relationship without one + // costs no derivation. + .filter(|(key, _)| { + info.identity_manager + .managed_identity(&Identifier::from(key.user_identity_id)) + .and_then(|managed| { + managed + .dashpay() + .established_contacts() + .get(&Identifier::from(key.friend_identity_id)) + }) + .is_some_and(|established| established.external_account_reference.is_some()) + }) + .filter(|(key, managed)| { + wallet + .accounts + .dashpay_external_accounts + .get(key) + .is_some_and(|account| { + managed + .managed_account_type() + .address_pools() + .into_iter() + .any(|pool| !pool_derives_from(pool, account)) + }) + }) + .map(|(key, _)| *key) + .collect(); + let mut cleared = 0; + for key in foreign { + let owner = Identifier::from(key.user_identity_id); + let contact = Identifier::from(key.friend_identity_id); + let marker = info + .identity_manager + .managed_identity_mut(&owner) + .and_then(|managed| managed.established_contact_mut(&contact)) + .and_then(|established| established.external_account_reference.take()); + if marker.is_some() { + cleared += 1; + } + } + cleared +} + +#[cfg(test)] +thread_local! { + /// How many restored pools [`pool_derives_from`] validated on this + /// thread, so a test can show a load did no derivation it did not need. + static POOL_VALIDATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many restored pools have been validated on this thread. +#[cfg(test)] +pub(crate) fn pool_validations_on_this_thread() -> usize { + POOL_VALIDATIONS.with(std::cell::Cell::get) +} + +/// Clear every restored outbound-account marker on `info`. Returns how many +/// were set. +fn clear_restored_outbound_markers(info: &mut PlatformWalletInfo) -> usize { + let mut cleared = 0; + for owner in info.identity_manager.identity_ids() { + let Some(managed) = info.identity_manager.managed_identity_mut(&owner) else { + continue; + }; + let contacts: Vec = managed + .dashpay() + .established_contacts() + .keys() + .copied() + .collect(); + for contact in contacts { + if let Some(established) = managed.established_contact_mut(&contact) { + if established.external_account_reference.take().is_some() { + cleared += 1; + } + } + } + } + cleared +} + +/// Whether every address in `pool` is the one `account` derives at its index. +/// +/// Each restored index is derived on its own through key-wallet's +/// [`AccountDerivation::derive_address_at`] — the derivation that generates +/// the pool — so the cost follows the restored rows (a sparse row at a huge +/// index costs one derivation) and the check cannot drift from address +/// generation. The comparison is by script, which is what the pool watches +/// and pays to, so an address encoded for another network still matches its +/// key. A failed derivation counts as a mismatch, and a hardened pool never +/// matches: its children cannot be derived from a public key. +fn pool_derives_from(pool: &AddressPool, account: &key_wallet::Account) -> bool { + #[cfg(test)] + POOL_VALIDATIONS.with(|count| count.set(count.get() + 1)); + if pool.pool_type == AddressPoolType::AbsentHardened { + return pool.addresses.is_empty(); + } + pool.addresses.iter().all(|(index, info)| { + account + .derive_address_at(pool.pool_type, *index, None) + .is_ok_and(|address| address.script_pubkey() == info.script_pubkey) + }) +} + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1531,4 +1832,64 @@ mod tests { idle adapter holds no strong reference to await" ); } + + /// Restored-address validation costs one derivation per restored row: a + /// single sparse row at index 1,000,000 is checked directly, not by + /// deriving every index below it, and a row another xpub derived is still + /// caught. The expected addresses come from key-wallet's own per-index + /// derivation, so the validator cannot drift from it unnoticed + /// (dashpay/platform#4302 review). + #[test] + fn restored_address_validation_is_proportional_to_the_restored_rows() { + use key_wallet::account::derivation::AccountDerivation; + use key_wallet::account::AccountType; + use key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; + use key_wallet::managed_account::address_pool::AddressPoolType; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::managed_account::ManagedCoreFundsAccount; + + let account = |seed: u8| key_wallet::Account { + parent_wallet_id: None, + account_type: AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: [0xAA; 32], + friend_identity_id: [0x01; 32], + }, + network: dashcore::Network::Testnet, + account_xpub: ExtendedPubKey::from_priv( + &ExtendedPrivKey::new_master(dashcore::Network::Testnet, &[seed; 32]) + .expect("master"), + ), + is_watch_only: true, + }; + let (current, previous) = (account(1), account(2)); + let managed = ManagedCoreFundsAccount::from_account(¤t); + let pool = managed.managed_account_type().address_pools()[0].clone(); + assert_eq!(pool.pool_type, AddressPoolType::Absent); + assert!(super::pool_derives_from(&pool, ¤t)); + + let sparse = |from: &key_wallet::Account| { + let mut pool = pool.clone(); + let mut info = pool.addresses[&0].clone(); + info.index = 1_000_000; + info.address = from + .derive_address_at(AddressPoolType::Absent, 1_000_000, None) + .expect("key-wallet derives the row"); + info.script_pubkey = info.address.script_pubkey(); + pool.addresses.insert(1_000_000, info); + pool.highest_generated = Some(1_000_000); + pool + }; + + let started = std::time::Instant::now(); + assert!(super::pool_derives_from(&sparse(¤t), ¤t)); + assert!( + !super::pool_derives_from(&sparse(&previous), ¤t), + "a row the current account does not derive is caught" + ); + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "validation must not derive every index below the sparse row" + ); + } } diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index a442f8a48c4..6ea8ba9b5fd 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -5,6 +5,8 @@ pub mod dashpay_sync; pub mod dpns_sync; pub mod identity_sync; mod load; +#[cfg(test)] +pub(crate) use load::pool_validations_on_this_thread; mod persistence_load; pub mod platform_address_sync; #[cfg(feature = "shielded")] @@ -14,6 +16,7 @@ mod wallet_lifecycle; pub(crate) use persistence_load::run_blocking_load; +use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; @@ -22,9 +25,16 @@ use tokio::sync::{Notify, RwLock}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; +use dpp::prelude::Identifier; +use key_wallet::wallet::ManagedWalletInfo; use key_wallet_manager::WalletManager; -use crate::changeset::{spawn_wallet_event_adapter, PlatformWalletPersistence}; +use crate::changeset::{ + persists_backfill_coverage, spawn_wallet_event_adapter_with_durable_cursors, CoreChangeSet, + DashPayBackfillRecord, DurableCursor, DurableCursors, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use crate::error::PlatformWalletError; use crate::events::{PlatformEventHandler, PlatformEventManager}; use crate::manager::dashpay_sync::DashPaySyncManager; use crate::manager::dpns_sync::DpnsSyncManager; @@ -439,6 +449,10 @@ pub struct PlatformWalletManager { /// failed / rescan pending" state rather than re-freezing silently on /// the next launch. pub(super) sync_fault: Arc, + /// The durable sync cursor each wallet's host last accepted, shared by + /// the wallet-event adapter and the DashPay rescan reconcile — the lock + /// that orders their cursor writes. See [`DurableCursors`]. + pub(super) durable_cursors: DurableCursors, /// Per-WALLET in-broadcast fence maps, handed to every /// [`WalletGeneration`](crate::wallet::core::WalletGeneration) registered /// under each id. @@ -466,6 +480,39 @@ pub struct PlatformWalletManager { >, } +/// Keep `record`'s coverage only for the DashPay receival accounts `core` +/// actually holds, and report whether anything was dropped. +/// +/// Coverage vouches that an account's scripts were watched; for an account +/// the published wallet does not hold, nothing watches them, and a scan the +/// wallet commits before the account is registered again would sit inside +/// coverage that never saw it. Both publish paths apply this before +/// [`PlatformWalletManager::settle_published_backfill`], which replaces the +/// host's record when it reports a drop. Dropping the last entry also drops +/// the extent: nothing is left for it to describe. +pub(crate) fn retain_coverage_for_held_accounts( + record: &mut DashPayBackfillRecord, + core: &ManagedWalletInfo, +) -> bool { + let held: Vec<(Identifier, Identifier, u32)> = core + .accounts + .dashpay_receival_accounts + .keys() + .map(|key| { + ( + Identifier::from(key.user_identity_id), + Identifier::from(key.friend_identity_id), + key.index, + ) + }) + .collect(); + let dropped = record.retain(|owner, contact, index| held.contains(&(*owner, *contact, index))); + if dropped && record.is_empty() { + *record = DashPayBackfillRecord::default(); + } + dropped +} + impl PlatformWalletManager

{ /// Create a new PlatformWalletManager. /// @@ -500,16 +547,18 @@ impl PlatformWalletManager

{ // Host-visible hard sync-fault latch. The // adapter raises it the first time it freezes a durable watermark. let sync_fault = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let durable_cursors = DurableCursors::default(); // Spawn the wallet-event adapter that translates upstream // `WalletEvent`s into `CoreChangeSet`s and forwards them to // the persister. let event_adapter_cancel = CancellationToken::new(); - let event_adapter_join = spawn_wallet_event_adapter( + let event_adapter_join = spawn_wallet_event_adapter_with_durable_cursors( Arc::clone(&wallet_manager), Arc::downgrade(&persister), event_receiver, Arc::clone(&sync_fault), + Arc::clone(&durable_cursors), event_adapter_cancel.clone(), ); @@ -607,10 +656,163 @@ impl PlatformWalletManager

{ event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), registry, sync_fault, + durable_cursors, in_broadcast_fences: std::sync::Mutex::new(std::collections::BTreeMap::new()), } } + /// Remove `wallet_id` from the inner manager, keeping its rewind + /// barrier's state under the id in the shared [`DurableCursors`] for a + /// successor registered under the same id (see + /// [`Self::inherit_rewind_barrier`]) — and for the adapter, which drops + /// the removed wallet's advances while nothing holds the id and counts + /// them off this state as it does. Called under the manager write lock, + /// which is what makes the hand-off atomic against the adapter. + pub(super) fn remove_from_wallet_manager( + &self, + wm: &mut WalletManager, + wallet_id: &WalletId, + ) -> Result<(), key_wallet_manager::WalletError> { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let (_, info) = wm.remove_wallet(wallet_id)?; + let retiring = info + .rewind_barrier + .retire(WalletInfoInterface::account_generation(&info)); + let mut retired = self.durable_cursors.retired(); + let entry = retired.entry(*wallet_id).or_default(); + entry.in_flight = entry.in_flight.saturating_add(retiring.in_flight); + entry.epoch = entry.epoch.max(retiring.epoch); + entry.account_generation = entry.account_generation.max(retiring.account_generation); + Ok(()) + } + + /// Settle the host's DashPay backfill record for a wallet about to be + /// published under `wallet_id`, holding the durable-cursor lock + /// (`durable_cursors`), and return the record the wallet carries in + /// memory. + /// + /// `record` is what the wallet would carry — its snapshot's record, + /// already filtered to the receival accounts it holds — and `own_cursor` + /// the scan cursor it starts from. `snapshot_cursor` is the host cursor + /// the pre-lock snapshot saw; an entry in `durable_cursors` wins over it. + /// `dropped` says the filter removed coverage from the snapshot's record. + /// + /// The host's record is replaced, before the wallet is visible to the + /// scanner, when it may disagree with what the wallet carries: + /// - the filter dropped coverage for accounts this wallet does not hold; + /// - an entry shows a same-id wallet was published in this process — it + /// may have stored or invalidated coverage after the snapshot was read; + /// - the backend cannot hold the record durably and the snapshot held one. + /// + /// A replacement that puts coverage on the host while this wallet's cursor + /// is below the host's carries that cursor, as a rewind, in the same + /// round. Otherwise a crash right after it would restore the coverage + /// beside a cursor the wallet never scanned up to with those accounts + /// watched. The entry follows the cursor only once the round is + /// committed, so a lower cursor owed later is measured against it. + /// + /// A backend that cannot hold the record durably + /// ([`persists_backfill_coverage`](crate::changeset::persists_backfill_coverage): + /// not inline, or not atomic) gets no record at all, the same as the + /// rescan sweep gives it: only an empty one is written — committed with a + /// `flush` where `store` only buffers — before the wallet is published, + /// so a record an earlier run left cannot outlive this one. + #[allow(clippy::too_many_arguments)] + pub(super) fn settle_published_backfill( + &self, + durable_cursors: &mut BTreeMap, + wallet_id: WalletId, + record: DashPayBackfillRecord, + dropped: bool, + own_cursor: u32, + snapshot_cursor: u32, + ) -> Result { + let inline = self.persister.store_commits_inline(); + let durable = persists_backfill_coverage(&*self.persister); + let predecessor = durable_cursors.contains_key(&wallet_id); + let held_on_host = !record.is_empty() || dropped; + let record = if durable { + record + } else { + DashPayBackfillRecord::default() + }; + // A backend that cannot hold the record durably never had one + // written by this process, so only a record it handed back can need + // invalidating; one that can is replaced when the snapshot's record + // may disagree with what the wallet carries. + let replace = if durable { + dropped || predecessor + } else { + held_on_host + }; + if !replace { + return Ok(record); + } + let host_cursor = durable_cursors + .get(&wallet_id) + .map_or(snapshot_cursor, |entry| entry.height); + let cursor = (!record.is_empty() && own_cursor < host_cursor).then_some(own_cursor); + tracing::info!( + wallet_id = %hex::encode(wallet_id), + covered = record.covered().len(), + dropped, + predecessor, + durable, + cursor = ?cursor, + "the host's DashPay backfill record is replaced with the one this wallet \ + carries before the wallet is published" + ); + let changeset = PlatformWalletChangeSet { + core: cursor.map(|cursor| CoreChangeSet { + synced_height: Some(cursor), + synced_height_is_rewind: true, + ..CoreChangeSet::default() + }), + dashpay_backfill: Some(record.clone()), + ..PlatformWalletChangeSet::default() + }; + self.persister.store(wallet_id, changeset).map_err(|e| { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "failed to replace the host's DashPay backfill record; \ + the wallet was not published" + ); + PlatformWalletError::from_store_failure(&*self.persister, e) + })?; + if !inline { + self.persister.flush(wallet_id).map_err(|e| { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "failed to commit the replaced DashPay backfill record; \ + the wallet was not published" + ); + PlatformWalletError::from_flush_failure(e) + })?; + } + if let Some(cursor) = cursor { + durable_cursors.insert(wallet_id, DurableCursor::at(cursor)); + } + Ok(record) + } + + /// Hand a newly published wallet the barrier state a removed wallet of + /// the same id left behind, so advances that wallet emitted — still + /// queued in, or already projected by, the shared adapter — are dropped + /// rather than stored against the new wallet's scan. Called inside the + /// critical section that publishes the wallet. + pub(super) fn inherit_rewind_barrier( + &self, + wm: &mut WalletManager, + wallet_id: &WalletId, + ) { + let retired = self.durable_cursors.retired().remove(wallet_id); + if let (Some(retired), Some(info)) = (retired, wm.get_wallet_info_mut(wallet_id)) { + info.rewind_barrier.inherit(retired); + } + } + /// The in-broadcast fence map for `wallet_id`, creating it on first use. /// /// Every [`WalletGeneration`](crate::wallet::core::WalletGeneration) this @@ -1192,6 +1394,8 @@ mod tests { Arc::new(Notify::new()), Arc::new(NoopPersister) as Arc, Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), )); mgr.wallets.rcu(|wallets| { let mut next = std::collections::BTreeMap::clone(wallets); diff --git a/packages/rs-platform-wallet/src/manager/startup.rs b/packages/rs-platform-wallet/src/manager/startup.rs index 04c07d50832..36f4721bb3a 100644 --- a/packages/rs-platform-wallet/src/manager/startup.rs +++ b/packages/rs-platform-wallet/src/manager/startup.rs @@ -748,6 +748,47 @@ impl PlatformWalletManager ); } + // The DashPay sync above ran its rescan sweep BEFORE the drain created + // the receival accounts, so it saw none of them. Run it once more now: + // with `synced_height == 0` (this sequence runs before SPV starts) the + // sweep marks every registered receival contact as covered by the + // coming full scan, instead of leaving it for the first post-scan + // sweep to rewind for. + // + // Budgeted, because it is not free: it waits for the cursor and + // manager locks, and on a backend that persists backfill coverage it + // stores the record inline. `within_budget` is safe here: the sweep's + // only await points are those two lock acquisitions, taken before it + // changes anything, and everything after them (the store included) + // runs to completion without yielding, so a drop can only land before + // the pass begins. Skipping it costs a scan, never a payment: the + // contact stays uncovered, and the first sweep after SPV starts + // rewinds to its funding height instead. + if drained > 0 { + match within_budget( + deadline, + identity_wallet.dashpay().reconcile_dashpay_rescan(), + ) + .await + { + Some(Ok(_)) => {} + Some(Err(e)) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "startup: post-drain DashPay rescan sweep failed" + ); + } + None => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + "startup: budget spent before the post-drain DashPay rescan sweep; \ + the first sweep after SPV starts will backfill the drained contacts" + ); + } + } + } + Ok(tally.into_outcome(started.elapsed())) } @@ -1404,12 +1445,26 @@ mod tests { std::sync::Arc>, WalletId, ) { + manager_with_queued_contact_crypto_on(std::sync::Arc::new( + crate::test_support::NoopTestPersister, + )) + .await + } + + /// [`manager_with_queued_contact_crypto`] over a caller-supplied backend. + async fn manager_with_queued_contact_crypto_on

( + backend: std::sync::Arc

, + ) -> (std::sync::Arc>, WalletId) + where + P: crate::changeset::PlatformWalletPersistence + 'static, + { use crate::changeset::{ upsert_pending_contact_crypto, PendingContactCrypto, PendingContactCryptoOp, }; use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; - let (manager, wallet_id) = crate::test_support::test_platform_wallet_manager().await; + let (manager, wallet_id) = + crate::test_support::test_platform_wallet_manager_with(backend).await; let persister = WalletPersister::new(wallet_id, std::sync::Arc::new(NoPlatformPersistence)); let mut wm = manager.wallet_manager.write().await; @@ -1543,6 +1598,307 @@ mod tests { ); } + /// The drain creates the receival accounts AFTER the DashPay sync has + /// already run its rescan sweep, so that sweep saw none of them. Startup + /// therefore runs the sweep once more once the drain has produced + /// something. This pins the consequence: the freshly drained contact is + /// marked covered while `synced_height` is still 0, so when the full + /// historical scan later advances past its funding height the recurring + /// sweep does not rewind mid-session to backfill a contact the scan had + /// already covered. + #[tokio::test] + async fn the_post_drain_sweep_marks_the_drained_contact_covered() { + use crate::wallet::identity::network::SeedCryptoProvider; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let owner = Identifier::from([1u8; 32]); + let contact = Identifier::from([2u8; 32]); + let (manager, wallet_id) = manager_with_queued_contact_crypto().await; + establish_queued_contact(&manager, wallet_id).await; + let owning = + SeedCryptoProvider::from_seed(seed_for(OWNING_MNEMONIC), key_wallet::Network::Testnet); + + let outcome = manager + .start_wallet_subsystems( + &wallet_id, + None, + Some(&owning), + None::<&UnusedSigner>, + WalletStartupOptions::default(), + ) + .await + .expect("bring-up reports rather than raises"); + assert_eq!(outcome.contact_accounts_drained, 1); + + { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("wallet info"); + assert_eq!( + info.core_wallet.synced_height(), + 0, + "bring-up runs before SPV starts; the sweep must not have rewound anything" + ); + assert!( + info.identity_manager + .managed_identity(&owner) + .expect("managed identity") + .dashpay() + .rescan_triggered + .contains(&contact), + "the post-drain sweep must mark the drained contact covered by the coming scan" + ); + } + + // Advance the scan past the contact's funding height. The mark must + // hold: no rewind, because the full scan already covered it. + { + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + info.core_wallet.update_synced_height(200); + } + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("sweep"), + None, + "a contact covered by the full historical scan must never trigger a rewind" + ); + let wm = manager.wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("wallet info") + .core_wallet + .synced_height(), + 200 + ); + } + + /// Give the queued contact both requests, ours and its reply (funded at + /// 100), so the pair auto-establishes the relationship. The rescan sweep + /// only handles an ESTABLISHED contact, from its request pair's height. + async fn establish_queued_contact

( + manager: &crate::PlatformWalletManager

, + wallet_id: WalletId, + ) where + P: crate::changeset::PlatformWalletPersistence + 'static, + { + use crate::wallet::identity::ContactRequest; + use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; + + let owner = Identifier::from([1u8; 32]); + let contact = Identifier::from([2u8; 32]); + let persister = WalletPersister::new(wallet_id, std::sync::Arc::new(NoPlatformPersistence)); + let mut wm = manager.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&owner) + .expect("managed identity"); + managed + .add_sent_contact_request( + ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0), + &persister, + ) + .expect("sent request persists"); + managed + .add_incoming_contact_request( + ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], 100, 0), + &persister, + ) + .expect("incoming request persists"); + assert!( + managed + .dashpay() + .established_contacts() + .contains_key(&contact), + "the request pair establishes the contact the sweep handles" + ); + } + + /// A backend that persists backfill coverage, so the post-drain sweep + /// stores inline rather than staying in memory: inline commits, atomic + /// rounds and account writers, plus the backfill slot. Records the + /// coverage each round carried, and can hold the drain's account round + /// past the startup deadline — a slow host transaction. + #[derive(Default)] + struct DurableStartupBackend { + /// Coverage carried by each stored round that had a backfill record. + backfill_rounds: std::sync::Mutex>, + /// Whether a round carrying an account registration was stored. + account_round_stored: std::sync::atomic::AtomicBool, + /// When set, the next account round blocks this long before + /// committing. + stall_account_round: std::sync::Mutex>, + } + + impl crate::changeset::PlatformWalletPersistence for DurableStartupBackend { + fn store_commits_inline(&self) -> bool { + true + } + + fn stores_dashpay_backfill(&self) -> bool { + true + } + + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + use crate::changeset::PersistenceCapabilities as C; + C::ATOMIC_CHANGESETS.union(C::ACCOUNT_ADDRESS_POOLS) + } + + fn store( + &self, + _wallet_id: WalletId, + changeset: crate::changeset::PlatformWalletChangeSet, + ) -> Result<(), crate::changeset::PersistenceError> { + if !changeset.account_registrations.is_empty() { + if let Some(stall) = self.stall_account_round.lock().unwrap().take() { + std::thread::sleep(stall); + } + self.account_round_stored + .store(true, std::sync::atomic::Ordering::SeqCst); + } + if let Some(record) = changeset.dashpay_backfill { + self.backfill_rounds.lock().unwrap().push(record); + } + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), crate::changeset::PersistenceError> { + Ok(()) + } + + fn load( + &self, + ) -> Result + { + Ok(crate::changeset::ClientStartState::default()) + } + } + + fn rounds_covering(backend: &DurableStartupBackend, contact: Identifier) -> usize { + backend + .backfill_rounds + .lock() + .unwrap() + .iter() + .filter(|record| { + record + .covered() + .iter() + .any(|entry| entry.contact == contact) + }) + .count() + } + + /// On a backend that persists coverage, the post-drain sweep is a real + /// store, not a memory-only pass: the drained contact reaches the host as + /// covered on the same bring-up. + #[tokio::test] + async fn the_post_drain_sweep_stores_coverage_on_a_durable_backend() { + use crate::wallet::identity::network::SeedCryptoProvider; + + let contact = Identifier::from([2u8; 32]); + let backend = std::sync::Arc::new(DurableStartupBackend::default()); + let (manager, wallet_id) = manager_with_queued_contact_crypto_on(backend.clone()).await; + establish_queued_contact(&manager, wallet_id).await; + let owning = + SeedCryptoProvider::from_seed(seed_for(OWNING_MNEMONIC), key_wallet::Network::Testnet); + + let outcome = manager + .start_wallet_subsystems( + &wallet_id, + None, + Some(&owning), + None::<&UnusedSigner>, + WalletStartupOptions::default(), + ) + .await + .expect("bring-up reports rather than raises"); + + assert_eq!(outcome.contact_accounts_drained, 1); + assert_eq!( + rounds_covering(&backend, contact), + 1, + "the post-drain sweep must store the drained contact's coverage" + ); + } + + /// The post-drain sweep stores inline on a durable backend, so it must + /// respect the startup budget like every other step that can block. A + /// drain whose last host write ran past the deadline still completes and + /// is counted, but no further store is started: bring-up returns, and the + /// contact is left for the first sweep after SPV starts. + #[tokio::test] + async fn the_post_drain_sweep_starts_no_store_once_the_budget_is_spent() { + use crate::wallet::identity::network::SeedCryptoProvider; + + let owner = Identifier::from([1u8; 32]); + let contact = Identifier::from([2u8; 32]); + let budget = Duration::from_secs(1); + let backend = std::sync::Arc::new(DurableStartupBackend::default()); + let (manager, wallet_id) = manager_with_queued_contact_crypto_on(backend.clone()).await; + establish_queued_contact(&manager, wallet_id).await; + *backend.stall_account_round.lock().unwrap() = Some(budget + Duration::from_millis(500)); + let owning = + SeedCryptoProvider::from_seed(seed_for(OWNING_MNEMONIC), key_wallet::Network::Testnet); + + let outcome = manager + .start_wallet_subsystems( + &wallet_id, + None, + Some(&owning), + None::<&UnusedSigner>, + WalletStartupOptions { + budget, + ..WalletStartupOptions::default() + }, + ) + .await + .expect("bring-up reports rather than raises"); + + assert!( + backend + .account_round_stored + .load(std::sync::atomic::Ordering::SeqCst), + "the drain's account round is the write held past the deadline" + ); + assert_eq!(outcome.contact_accounts_drained, 1); + assert_eq!( + rounds_covering(&backend, contact), + 0, + "no coverage store may start once the budget is spent" + ); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + { + let wm = manager.wallet_manager.read().await; + assert!( + !wm.get_wallet_info(&wallet_id) + .expect("wallet info") + .identity_manager + .managed_identity(&owner) + .expect("managed identity") + .dashpay() + .rescan_triggered + .contains(&contact), + "a skipped sweep leaves the contact unhandled" + ); + } + + // The next sweep picks it up. + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("sweep"); + assert_eq!(rounds_covering(&backend, contact), 1); + } + /// The gate is paid for only when there is something to protect. An empty /// queue means the drain would derive nothing, so no key material is /// resolved — which is what keeps this affordable on a warm launch. @@ -1842,6 +2198,7 @@ mod tests { identity_manager: crate::changeset::IdentityManagerStartState::default(), unused_asset_locks: std::collections::BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), + dashpay_backfill: Default::default(), }, ); Ok(crate::changeset::ClientStartState { diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index bc211ce76cc..9366ca45d7d 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -370,6 +370,8 @@ impl PlatformWalletManager

{ identity_manager: crate::wallet::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), dpns_name_states: std::collections::BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), }; wallet.downgrade_to_external_signable(); @@ -412,12 +414,37 @@ impl PlatformWalletManager

{ // than sliced here: the authoritative id is the one that call returns, // and it is deliberately not assumed equal to `registration_wallet_id` // (see the divergence branch below). + // + // The same read also yields what the host holds for every persisted + // wallet: a registration that recreates a wallet the host already has + // must measure durable writes against the host's cursor, not the one + // it just built, and keeps the host's DashPay backfill record for the + // receival accounts it holds (see the seed below). let load_persister: Arc = Arc::clone(&self.persister) as _; - let mut persisted_platform_addresses = + let (mut persisted_platform_addresses, mut host_wallets) = match super::run_blocking_load(move || load_persister.load()).await { Ok(crate::changeset::ClientStartState { - platform_addresses, .. - }) => platform_addresses, + platform_addresses, + wallets, + .. + }) => ( + platform_addresses, + wallets + .into_iter() + .map(|(id, state)| { + ( + id, + ( + state.wallet_info.metadata.synced_height, + state.dashpay_backfill, + ), + ) + }) + .collect::>(), + ), Err(e) => { tracing::error!( wallet_id = %hex::encode(registration_wallet_id), @@ -436,16 +463,111 @@ impl PlatformWalletManager

{ // treat re-registering an existing wallet as a benign no-op // instead of substring-matching the error text. Everything else // stays `WalletCreation`. + // + // The durable cursor is seeded in the same scope as the insert, under + // the cursor lock taken first (its order: cursor lock, then manager). + // The insert makes the wallet visible to the scanner and the event + // adapter; seeding after it would let an adapter commit or a + // reconcile record a newer durable height first, only for the seed to + // overwrite it with the creation one. + // + // A registration can recreate a wallet the host already holds (the + // same seed added again on a fresh manager). The host keeps that + // row's cursor — registration writes no core cursor, and a host + // preserves `syncedHeight` on the metadata upsert — so the seed is + // the host's cursor, not the freshly built one. The snapshot above + // was read before this lock, and a same-id predecessor still + // registered then may have had a commit accepted since — or a whole + // same-id registration may have come and gone, creating the row the + // snapshot did not see. The entry those writes left in + // `DurableCursors` is what the host holds now, so it wins over the + // snapshot, row or no row. When the built cursor is lower than the + // host's, the wallet is in effect reset in memory only: that reset is + // owed to the host like any other, in this wallet's inherited epoch, + // and the next DashPay backfill record round carries it, so coverage + // never reaches disk beside the host's higher cursor + // (dashpay/platform#4302 review). + // + // The host's record is kept only for the receival accounts the + // recreated wallet actually holds — none, when it is built from the + // seed. Coverage for an account that is absent while this wallet + // scans is not coverage: the scan does not watch the account's + // scripts, and the account-generation guard only stops batches still + // in flight, not an advance already committed. The host still holds + // the old record and the account rows, and would restore both beside + // this wallet's advanced cursor after a crash — so the filtered + // record is stored BEFORE the wallet is published to the scanner. + // Those accounts re-arm when they are registered, at the cost of one + // more backfill. + // + // Whether the snapshot held anything to prune is not the whole test, + // because the snapshot is older than the lock: a same-id registration + // can have been published after it, established a receival account, + // stored that account's coverage and been removed again, all before + // this one publishes. Nothing in this snapshot shows that coverage. + // An entry in `DurableCursors` is what tells: every publication leaves + // one and removal keeps it. So with an entry present the host's + // record is replaced with the one this wallet carries, whatever the + // snapshot showed (`settle_published_backfill`). The duplicate check + // runs first, so a wallet still registered under this id never has + // its record overwritten; every publisher holds the cursor lock, so + // none can appear between that check and the insert. + let created_cursor = platform_info.core_wallet.metadata.synced_height; + let (snapshot_cursor, mut host_backfill) = + match host_wallets.remove(®istration_wallet_id) { + Some((cursor, record)) => (Some(cursor), record), + None => (None, Default::default()), + }; + let dropped = super::retain_coverage_for_held_accounts( + &mut host_backfill, + &platform_info.core_wallet, + ); let wallet_id = { + let mut durable_cursors = self.durable_cursors.lock().await; + if self + .wallet_manager + .read() + .await + .get_wallet_info(®istration_wallet_id) + .is_some() + { + return Err(already_registered(registration_wallet_id)); + } + let host_backfill = self.settle_published_backfill( + &mut durable_cursors, + registration_wallet_id, + host_backfill, + dropped, + created_cursor, + snapshot_cursor.unwrap_or(created_cursor), + )?; let mut wm = self.wallet_manager.write().await; - wm.insert_wallet(wallet, platform_info) + let wallet_id = wm + .insert_wallet(wallet, platform_info) .map_err(|e| match e { key_wallet_manager::WalletError::WalletExists(id) => already_registered(id), other => PlatformWalletError::WalletCreation(format!( "Failed to register wallet in WalletManager: {}", other )), - })? + })?; + let host_cursor = durable_cursors + .get(&wallet_id) + .map(|entry| entry.height) + .or(snapshot_cursor) + .unwrap_or(created_cursor); + self.inherit_rewind_barrier(&mut wm, &wallet_id); + if let Some(info) = wm.get_wallet_info_mut(&wallet_id) { + info.dashpay_backfill = host_backfill; + if created_cursor < host_cursor { + let epoch = info.rewind_barrier.epoch(); + info.dashpay_backfill.owe_cursor(created_cursor, epoch); + } + } + durable_cursors + .entry(wallet_id) + .or_insert(crate::changeset::DurableCursor::at(host_cursor)); + wallet_id }; // `insert_wallet` recomputes the id from the (now external-signable) @@ -543,7 +665,7 @@ impl PlatformWalletManager

{ "failed to persist wallet registration changeset" ); let mut wm = self.wallet_manager.write().await; - if let Err(remove_err) = wm.remove_wallet(&wallet_id) { + if let Err(remove_err) = self.remove_from_wallet_manager(&mut wm, &wallet_id) { tracing::warn!( wallet_id = %hex::encode(wallet_id), error = %remove_err, @@ -567,6 +689,8 @@ impl PlatformWalletManager

{ Arc::clone(&self.lock_notify), persister_dyn, broadcaster, + Arc::clone(&self.sync_fault), + Arc::clone(&self.durable_cursors), ); // Restore the platform-address provider from the slice read above — @@ -591,7 +715,7 @@ impl PlatformWalletManager

{ "failed to restore persisted platform-address state" ); let mut wm = self.wallet_manager.write().await; - if let Err(remove_err) = wm.remove_wallet(&wallet_id) { + if let Err(remove_err) = self.remove_from_wallet_manager(&mut wm, &wallet_id) { tracing::warn!( wallet_id = %hex::encode(wallet_id), error = %remove_err, @@ -877,7 +1001,7 @@ impl PlatformWalletManager

{ their identity-sync rows are left for the next launch to reconcile" ); } - if let Err(e) = wm.remove_wallet(wallet_id) { + if let Err(e) = self.remove_from_wallet_manager(&mut wm, wallet_id) { tracing::warn!( wallet_id = %hex::encode(wallet_id), error = %e, @@ -1238,6 +1362,155 @@ mod register_wallet_duplicate_tests { ); } + /// Removing a wallet keeps its rewind barrier's state, and a same-id + /// registration inherits it in the critical section that publishes it: + /// advances the removed wallet emitted cannot be stored against its + /// successor, and the successor's account generation starts past the + /// predecessor's, so no filter batch scanned against the predecessor's + /// account set is certified for it (dashpay/platform#4302 review). + #[tokio::test] + async fn a_same_id_registration_inherits_the_removed_wallets_barrier() { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let manager = make_manager(); + let network = Network::Testnet; + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); + let seed_bytes = mnemonic.to_seed(""); + let create = |manager: Arc>| async move { + manager + .create_wallet_from_seed_bytes( + network, + &seed_bytes, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + }; + let wallet_id = create(Arc::clone(&manager)) + .await + .expect("first create") + .wallet_id(); + let predecessor_generation = { + // Three advances emitted and not yet drained by the adapter, and + // a receival account registered. + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + for _ in 0..3 { + info.rewind_barrier.note_emitted(); + } + info.rewind_barrier.arm(); + info.rewind_barrier.note_account_registered(); + WalletInfoInterface::account_generation(info) + }; + manager.remove_wallet(&wallet_id).await.expect("remove"); + assert_eq!( + manager.durable_cursors.retired()[&wallet_id].in_flight, + 3, + "removal retires the barrier under the id" + ); + + create(Arc::clone(&manager)).await.expect("second create"); + assert!( + manager.durable_cursors.retired().get(&wallet_id).is_none(), + "the successor took the retired state over" + ); + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("published"); + let retired = info.rewind_barrier.retire(0); + assert_eq!( + retired.in_flight, 3, + "the predecessor's queued advances are carried" + ); + assert!(retired.epoch > 1, "the epoch moves past the predecessor's"); + assert!( + WalletInfoInterface::account_generation(info) > predecessor_generation, + "the successor's account generation starts past the predecessor's" + ); + } + + /// The durable cursor is seeded in the critical section that publishes + /// the wallet. Published first and seeded after, an adapter commit or a + /// reconcile could record a newer durable height in between and the seed + /// would overwrite it with the creation one. While the cursor lock is + /// held the registration must not publish the wallet at all, and once it + /// does, an entry an earlier same-id registration left is what the host + /// holds now, so it is kept and the lower created cursor is owed to it. + #[tokio::test] + async fn a_wallet_is_published_and_its_durable_cursor_seeded_in_one_step() { + let manager = make_manager(); + let network = Network::Testnet; + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); + let seed_bytes = mnemonic.to_seed(""); + let create = |manager: Arc>| async move { + manager + .create_wallet_from_seed_bytes( + network, + &seed_bytes, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + }; + + let wallet_id = create(Arc::clone(&manager)) + .await + .expect("first create") + .wallet_id(); + manager.remove_wallet(&wallet_id).await.expect("remove"); + + let mut gate = manager.durable_cursors.lock().await; + // What a removed registration might leave behind. + gate.insert(wallet_id, crate::changeset::DurableCursor::at(777)); + let registering = tokio::spawn(create(Arc::clone(&manager))); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .is_none(), + "no wallet may become visible while the durable-cursor lock is held" + ); + drop(gate); + + registering.await.expect("join").expect("second create"); + let created_cursor = { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .expect("published") + .core_wallet + .synced_height() + }; + assert_eq!( + manager + .durable_cursors + .lock() + .await + .get(&wallet_id) + .map(|cursor| cursor.height), + Some(777), + "the entry the earlier registration left is what the host holds" + ); + assert!(created_cursor < 777); + assert_eq!( + manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .expect("published") + .dashpay_backfill + .unpersisted_cursor, + Some(created_cursor), + "the lower created cursor is owed" + ); + } + /// PENDING-SPEND PROTECTION MUST SURVIVE WALLET RECREATION. /// /// If the in-broadcast fence lived in the `WalletGeneration` itself, it diff --git a/packages/rs-platform-wallet/src/masternode/locator.rs b/packages/rs-platform-wallet/src/masternode/locator.rs index 25083796d78..2790c2414dc 100644 --- a/packages/rs-platform-wallet/src/masternode/locator.rs +++ b/packages/rs-platform-wallet/src/masternode/locator.rs @@ -38,7 +38,7 @@ use dash_sdk::platform::Fetch; use dashcore::hashes::{hash160, Hash}; use dashcore::secp256k1::{PublicKey as SecpPublicKey, SecretKey as SecpSecretKey}; use dashcore::{Network, PrivateKey}; -use dpp::bls::SecretKey as BlsSecretKey; +use dpp::bls::{PublicKey as BlsPublicKey, SecretKey as BlsSecretKey}; use dpp::ed25519_dalek::SigningKey; use dpp::identifier::MasternodeIdentifiers; use dpp::identity::accessors::IdentityGettersV0; @@ -368,6 +368,41 @@ pub fn ecdsa_key_id(secret: &[u8; 32], compressed: bool) -> Option<[u8; 20]> { Some(hash160::Hash::hash(&bytes).to_byte_array()) } +/// The ids Platform may give the operator identity of a masternode whose list +/// entry carries `operator_public_key`, in the order to try them: the id +/// derived from those bytes, then, when they read as a basic-scheme key, the +/// id derived from the same key's legacy-scheme bytes. +/// +/// Platform derives the operator identity id from the operator key bytes it +/// stored, and keeps them when the listed bytes are the basic-scheme encoding +/// of the key the stored bytes encode in the legacy scheme. So a masternode +/// whose operator key was set in the legacy scheme keeps its legacy-derived +/// operator identity after Dash Core re-encodes the key in the basic scheme, +/// while the list then carries the basic bytes. When both identities exist, +/// the first is the current one. +/// +/// Legacy-scheme bytes with the top bit set also read as a basic-scheme key +/// (of the negated point), so they get a second id too, which no identity of +/// a legacy-scheme key holds. +pub fn operator_identity_ids( + pro_tx_hash_display: &[u8; 32], + operator_public_key: &[u8; 48], +) -> Vec { + let mut ids = vec![Identifier::create_operator_identifier( + pro_tx_hash_display, + operator_public_key, + )]; + if let Ok(legacy) = + BlsPublicKey::try_from(operator_public_key.as_slice()).and_then(|key| key.to_legacy_bytes()) + { + ids.push(Identifier::create_operator_identifier( + pro_tx_hash_display, + &legacy, + )); + } + ids +} + /// `(basic, legacy)` 48-byte serializations of the BLS public key for /// `secret`, or `None` when the scalar reduces to zero. pub fn bls_public_keys(secret: &[u8; 32]) -> Option<([u8; 48], [u8; 48])> { @@ -591,11 +626,10 @@ pub struct PlatformKeyRole { /// Interpret one identity returned by the non-unique-key-hash query for /// `key_id`: an owner identity (id == display proTxHash of a listed node) /// yields `Owner` / `OwnerPayout` by the matching key's purpose; an operator -/// identity (id == `create_operator_identifier(proTxHash, operator key)` of a -/// listed node) yields `OperatorPayout`. Anything else — a voter identity -/// (voting keys are matched from the list), a non-masternode identity that -/// happens to hold the key — yields nothing. Pure; the network step is the -/// caller's. +/// identity (an id from [`operator_identity_ids`] of a listed node) yields +/// `OperatorPayout`. Anything else — a voter identity (voting keys are +/// matched from the list), a non-masternode identity that happens to hold the +/// key — yields nothing. Pure; the network step is the caller's. pub fn platform_roles_from_identity( identity: &Identity, key_id: &[u8; 20], @@ -631,19 +665,19 @@ pub fn platform_roles_from_identity( .collect(); } - // Operator identity of some listed node? + // Operator identity of some listed node? Only its payout key is ours. + if !identity + .public_keys() + .values() + .any(|k| k.data().as_slice() == key_id && k.purpose() == Purpose::TRANSFER) + { + return Vec::new(); + } let id = identity.id(); summaries .iter() .filter(|s| { - Identifier::create_operator_identifier(&s.pro_tx_hash_display(), &s.operator_public_key) - == id - }) - .filter(|_| { - identity - .public_keys() - .values() - .any(|k| k.data().as_slice() == key_id && k.purpose() == Purpose::TRANSFER) + operator_identity_ids(&s.pro_tx_hash_display(), &s.operator_public_key).contains(&id) }) .map(|s| PlatformKeyRole { pro_tx_hash: s.pro_tx_hash, @@ -1370,6 +1404,73 @@ mod tests { ); } + /// Platform keeps the operator key bytes it stored when the key was set, so a masternode + /// registered with a legacy-scheme operator key keeps the operator identity derived from the + /// legacy bytes after Dash Core re-encodes the key in the basic scheme, while the list then + /// shows the basic bytes. + #[test] + fn operator_identity_of_a_re_encoded_legacy_key_yields_operator_payout_role() { + let (legacy, basic) = CORE_RE_ENCODINGS[1]; + let (legacy, basic) = (key_bytes(legacy), key_bytes(basic)); + let mut summaries = list(); + summaries[1].operator_public_key = basic; + let node = &summaries[1]; + let payout_hash = [0x66u8; 20]; + let id = Identifier::create_operator_identifier(&node.pro_tx_hash_display(), &legacy); + let identity = identity_with(id.to_buffer(), vec![(Purpose::TRANSFER, payout_hash)]); + assert_eq!( + platform_roles_from_identity(&identity, &payout_hash, &summaries), + vec![PlatformKeyRole { + pro_tx_hash: node.pro_tx_hash, + role: MasternodeKeyRole::OperatorPayout + }] + ); + } + + /// Operator keys as Dash Core v24.0.0-rc.3 printed them before and after + /// re-encoding them: (legacy, basic), legacy sign bit clear and set. + const CORE_RE_ENCODINGS: [(&str, &str); 2] = [ + ( + "06ebb5ebd9079d73577e0900c9c10139d7d7213247e32b9b8823cd80e6a18e16a497ac57508e23b4a0e0510bfc880ad9", + "86ebb5ebd9079d73577e0900c9c10139d7d7213247e32b9b8823cd80e6a18e16a497ac57508e23b4a0e0510bfc880ad9", + ), + ( + "910633a672392e26860cbff612b28d84f71beb34c2092d015b0d522905d628d38d746c1088c654632c65c6a7f008095d", + "b10633a672392e26860cbff612b28d84f71beb34c2092d015b0d522905d628d38d746c1088c654632c65c6a7f008095d", + ), + ]; + + fn key_bytes(hex: &str) -> [u8; 48] { + hex::decode(hex).unwrap().try_into().unwrap() + } + + /// A basic-scheme key Core re-encoded yields the id from its bytes first, + /// then the id from the legacy bytes Core printed before. Legacy bytes with + /// the sign bit clear read as no basic-scheme key and yield only their own + /// id; with the sign bit set they also read as one (of the negated point) + /// and yield a second id. + #[test] + fn operator_identity_ids_add_the_legacy_encoding_of_a_basic_key() { + let pro_tx_hash = [0x5Au8; 32]; + let id = |key: &[u8; 48]| Identifier::create_operator_identifier(&pro_tx_hash, key); + for (legacy, basic) in CORE_RE_ENCODINGS { + let (legacy, basic) = (key_bytes(legacy), key_bytes(basic)); + assert_eq!( + operator_identity_ids(&pro_tx_hash, &basic), + vec![id(&basic), id(&legacy)] + ); + } + let sign_clear = key_bytes(CORE_RE_ENCODINGS[0].0); + assert_eq!( + operator_identity_ids(&pro_tx_hash, &sign_clear), + vec![id(&sign_clear)] + ); + let sign_set = key_bytes(CORE_RE_ENCODINGS[1].0); + let ids = operator_identity_ids(&pro_tx_hash, &sign_set); + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], id(&sign_set)); + } + #[test] fn unrelated_identity_yields_nothing() { let identity = identity_with([0xEEu8; 32], vec![(Purpose::TRANSFER, secp_key_id())]); diff --git a/packages/rs-platform-wallet/src/masternode/tracked.rs b/packages/rs-platform-wallet/src/masternode/tracked.rs index 039076a777b..7350000a7f5 100644 --- a/packages/rs-platform-wallet/src/masternode/tracked.rs +++ b/packages/rs-platform-wallet/src/masternode/tracked.rs @@ -34,7 +34,6 @@ use dashcore::hashes::Hash; use dashcore::transaction::special_transaction::provider_registration::ProviderMasternodeType; use dashcore::transaction::TransactionPayload; use dashcore::{Address as DashAddress, Network}; -use dpp::identifier::MasternodeIdentifiers; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{Identity, Purpose}; @@ -42,7 +41,9 @@ use dpp::prelude::Identifier; use serde_json::{json, Value}; use super::list::MasternodeListSummary; -use super::locator::{p2pkh_script_hash, MasternodeKeyReference, MasternodeKeyRole}; +use super::locator::{ + operator_identity_ids, p2pkh_script_hash, MasternodeKeyReference, MasternodeKeyRole, +}; use super::record::{ owner_payout_script, platform_http_port, ListMembership, MasternodeRecord, MasternodeSource, MasternodeStatus, @@ -593,6 +594,26 @@ where Ok((tracked, outcome)) } +/// Fetch the first identity found among the candidate operator identity +/// `ids`, in order. A missing identity moves on to the next id; a found one +/// ends the search. A failed fetch ends it too, with the error: the next id +/// may find an older identity while the current one was unreadable. +async fn first_operator_identity( + ids: impl IntoIterator, + mut fetch: F, +) -> Result, E> +where + F: FnMut(Identifier) -> Fut, + Fut: std::future::Future, E>>, +{ + for id in ids { + if let Some(identity) = fetch(id).await? { + return Ok(Some(identity)); + } + } + Ok(None) +} + impl std::fmt::Debug for TrackedMasternodes { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TrackedMasternodes") @@ -869,8 +890,11 @@ impl TrackedMasternodes { .as_ref() .map(|r| r.operator_public_key)); if let Some(operator_key) = operator_key { - let operator_id = Identifier::create_operator_identifier(&display, &operator_key); - match Identity::fetch(self.sdk.as_ref(), operator_id).await { + match first_operator_identity(operator_identity_ids(&display, &operator_key), |id| { + Identity::fetch(self.sdk.as_ref(), id) + }) + .await + { Ok(Some(identity)) => { let mut platform = snapshot.platform.clone().unwrap_or_default(); platform.operator_payout_key_hash = identity @@ -1386,6 +1410,78 @@ mod tests { assert_eq!(persisted, vec![live]); } + fn bare_identity(id: Identifier) -> Identity { + use dpp::identity::v0::IdentityV0; + IdentityV0 { + id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + } + .into() + } + + /// The operator identity id derived from the listed key bytes, then the + /// one derived from the same key's legacy-scheme bytes. + fn operator_candidates() -> (Identifier, Identifier) { + (Identifier::from([1u8; 32]), Identifier::from([2u8; 32])) + } + + /// Run the operator identity lookup over `[listed, legacy]`, answering + /// each fetch with `answer`. Returns the id of the identity it settled + /// on (or its error) and every id it requested, in order. + async fn lookup_operator_identity( + answer: impl Fn(Identifier) -> Result, String>, + ) -> (Result, String>, Vec) { + let (listed, legacy) = operator_candidates(); + let mut requested = Vec::new(); + let found = first_operator_identity([listed, legacy], |id| { + requested.push(id); + std::future::ready(answer(id)) + }) + .await; + (found.map(|identity| identity.map(|i| i.id())), requested) + } + + /// A node whose operator key Core re-encodes has no identity under the + /// listed bytes; its identity lives under the legacy-scheme id. + #[tokio::test] + async fn operator_identity_lookup_falls_back_to_the_legacy_id_when_the_listed_one_is_missing() { + let (listed, legacy) = operator_candidates(); + let (found, requested) = + lookup_operator_identity(|id| Ok((id == legacy).then(|| bare_identity(id)))).await; + assert_eq!(requested, vec![listed, legacy]); + assert_eq!(found, Ok(Some(legacy))); + } + + /// An identity under the listed bytes is the current one: the legacy id + /// is never requested, even when an identity exists there too. + #[tokio::test] + async fn operator_identity_lookup_stops_at_the_first_identity_found() { + let (listed, _) = operator_candidates(); + let (found, requested) = lookup_operator_identity(|id| Ok(Some(bare_identity(id)))).await; + assert_eq!(requested, vec![listed]); + assert_eq!(found, Ok(Some(listed))); + } + + /// A failed fetch of the listed id is reported, not papered over with + /// the identity under the legacy id: that one may be stale while the + /// current one was merely unreadable. + #[tokio::test] + async fn operator_identity_lookup_returns_a_fetch_error_without_trying_the_next_id() { + let (listed, _) = operator_candidates(); + let (found, requested) = lookup_operator_identity(|id| { + if id == listed { + Err("listed id unreadable".to_string()) + } else { + Ok(Some(bare_identity(id))) + } + }) + .await; + assert_eq!(requested, vec![listed]); + assert_eq!(found, Err("listed id unreadable".to_string())); + } + #[test] fn record_prefers_live_list_then_snapshot_then_registration() { let t = tracked(); diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 63e42594fc6..9879ceb1041 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -251,6 +251,8 @@ pub async fn funded_wallet_manager_with_outputs( identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -380,6 +382,8 @@ pub(crate) async fn funded_wallet_manager_dual_standard( identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), }; let mut wm = WalletManager::::new(Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); @@ -483,6 +487,8 @@ pub(crate) async fn funded_wallet_manager_with_contact( identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), }; let mut wm = WalletManager::::new(Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); @@ -560,6 +566,8 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -663,6 +671,17 @@ pub async fn test_platform_wallet_manager() -> ( Arc>, WalletId, ) { + test_platform_wallet_manager_with(Arc::new(NoopTestPersister)).await +} + +/// [`test_platform_wallet_manager`] over a caller-supplied persister, for tests +/// whose behaviour depends on what the backend attests. +pub async fn test_platform_wallet_manager_with

( + persister: Arc

, +) -> (Arc>, WalletId) +where + P: crate::changeset::PlatformWalletPersistence + 'static, +{ use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; @@ -671,7 +690,6 @@ pub async fn test_platform_wallet_manager() -> ( abandon abandon abandon abandon abandon about"; let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); - let persister = Arc::new(NoopTestPersister); let event_handler: Arc = Arc::new(NoopTestEventHandler); let manager = Arc::new(crate::PlatformWalletManager::new( @@ -765,6 +783,8 @@ pub(crate) async fn mnemonic_wallet_manager( identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), }; let mut wm = WalletManager::::new(Network::Testnet); diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 0f4ec4a8c33..c781c901e43 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -109,6 +109,10 @@ impl PlatformWalletInfo { invitations: _, dpns_name_states, identity_scan_state, + // The backfill record is persistence-only here: the reconcile + // sweep mutates `PlatformWalletInfo::dashpay_backfill` directly + // and the start-state path restores it at load. No replay hook. + dashpay_backfill: _, // Registration-round metadata / per-account specs / // per-pool snapshots are persistence-only — the // canonical in-memory wallet state is built up at @@ -442,6 +446,8 @@ mod tests { tracked_asset_locks: BTreeMap::new(), observed_input_conflicts: Default::default(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index a14417dba53..6736299079d 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -664,6 +664,8 @@ mod tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), }; assert_eq!( funding_account_index(&info, &txid), diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 7e886ea7da6..ec725024579 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -2524,6 +2524,8 @@ mod tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), }; let out_point = OutPoint::new(tx.txid(), 0); let lock = TrackedAssetLock { diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 1879f18346c..b4f4813facf 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -819,6 +819,8 @@ mod tests { Arc::new(crate::test_support::NoopTestPersister) as Arc, Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), )) } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 79cf0857bbf..8d8753248a4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -1100,11 +1100,16 @@ pub(crate) fn budget_spent(deadline: Option) -> bool { /// A permanently-broken channel is left alone (the sweep never rebuilds /// broken contacts; they heal on a superseding request). The staleness test /// is `external_account_reference != Some(incoming_request.account_reference)`: -/// a mismatch (or a `None` marker from a cold restart that did not carry it) -/// means the persisted, tombstone-less account row rebuilt the rotated-away -/// xpub while the contact already tracks the new reference — so `send_payment` -/// would derive addresses the contact no longer watches until it is rebuilt. -fn external_account_needs_rebuild(contact: &EstablishedContact, has_external: bool) -> bool { +/// a mismatch (or a `None` marker from a cold restart on a host that did not +/// carry it — the in-tree hosts persist it on the established rows, so a +/// healthy account survives a restart without a rebuild) means the persisted, +/// tombstone-less account row rebuilt the rotated-away xpub while the contact +/// already tracks the new reference — so `send_payment` would derive addresses +/// the contact no longer watches until it is rebuilt. +pub(super) fn external_account_needs_rebuild( + contact: &EstablishedContact, + has_external: bool, +) -> bool { has_external && !contact.payment_channel_broken && contact.external_account_reference != Some(contact.incoming_request.account_reference) @@ -1867,7 +1872,7 @@ impl DashPayView<'_, B> { /// permanently broken — the account-building candidates for this /// sweep. Runs under the caller's write guard; performs no /// awaits and no lock re-acquisition. - fn collect_account_build_candidates( + pub(super) fn collect_account_build_candidates( info: &crate::wallet::platform_wallet::PlatformWalletInfo, identity_id: &Identifier, ) -> Vec { @@ -3211,7 +3216,10 @@ impl DashPayView<'_, B> { /// /// - Stamping `external_account_reference` lets the sweep's rotation /// self-heal skip a healthy account (marker matches the tracked - /// reference) and detect a stale one after a restart (F2). + /// reference) and detect a stale one after a restart (F2). On a + /// backend that keeps markers on disk the registration round already + /// bound it, and this only clears the broken flag while that marker is + /// still current; it never stamps one there. /// - Clearing `payment_channel_broken` heals a channel that a prior /// permanent-fault marked broken but that a successful re-register (via /// the drain or a user re-accept) proves is usable again — otherwise the @@ -3259,6 +3267,18 @@ impl DashPayView<'_, B> { } let current_reference = contact.incoming_request.account_reference; let already_current = contact.external_account_reference == Some(current_reference); + // Where markers are kept on disk, registration is their only writer: + // it binds the marker in the account's own round, to the request the + // account was built from. A completion arriving late — after the + // account was torn down and rebuilt, or from a request that reused + // the same ciphertext under another reference — could otherwise + // stamp the live reference beside an obsolete durable account, and a + // restart would trust the pairing. So it only heals the broken flag, + // and only while the marker registration bound is still the live one + // (dashpay/platform#4302 review). + if self.persister.persists_outbound_account_markers() && !already_current { + return; + } if already_current && !contact.payment_channel_broken { return; } @@ -3378,7 +3398,7 @@ impl DashPayView<'_, B> { /// One established contact that needs its DashPay accounts (re)built /// during a sync sweep. Collected under the write guard, consumed /// after it is dropped. -struct AccountBuildCandidate { +pub(super) struct AccountBuildCandidate { /// The counterparty identity. contact_id: Identifier, /// The counterparty's 96-byte encrypted xpub (from their incoming @@ -4202,6 +4222,8 @@ mod sweep_tests { tracked_asset_locks: BTreeMap::new(), observed_input_conflicts: Default::default(), dpns_name_states: BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs index a19288f4128..46e6eb487c0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -87,6 +87,10 @@ pub enum ExternalAccountRegistration { /// An account row already existed for the `(index, user, friend)` key; /// nothing was decrypted or replaced. AlreadyExisted, + /// The contact's live incoming request no longer carries the supplied + /// encrypted xpub — a rotation landed after the caller read it. Nothing + /// was persisted or inserted; the sweep rebuilds from the live request. + Superseded, } impl RegisterExternalError { @@ -247,6 +251,10 @@ impl DashPayView<'_, B> { "Failed to register contact account: {e}" )) })?; + // A filter batch in flight right now was scanned without this + // account's scripts: bump the account generation so the pipeline does + // not certify it, and the range is rescanned with the account watched. + info.rewind_barrier.note_account_registered(); tracing::info!( our_identity = %our_identity_id, @@ -402,16 +410,21 @@ impl DashPayView<'_, B> { /// tell a real (re)build from an already-existed no-op — only the former /// may stamp the rotation self-heal marker (see the enum docs). /// - /// **Never wrap this call in a timeout.** Unlike its sibling - /// [`Self::register_contact_account`], which persists inside the write lock - /// it already holds, this one persists *before* acquiring the lock — so - /// there is a genuine `.await` between the durable write and the in-memory - /// inserts. A future dropped in that window leaves an account on disk that - /// no in-memory collection knows about for the rest of the process's life; - /// a real crash reloads it from disk, an in-process cancellation does not. - /// (The next drain does re-register it idempotently, so this is a landmine - /// rather than data loss — but the asymmetry with the sibling is not - /// something to rediscover by analogy.) + /// The absence check, the durable write and the in-memory inserts run + /// under ONE wallet-manager write guard, so registrations of the same + /// account are serialized: a registration that decrypted a payload and + /// then lost the race to another one finds the account present and + /// returns [`ExternalAccountRegistration::AlreadyExisted`], instead of + /// overwriting the newer account's durable xpub and pool. Under the same + /// guard the payload is checked against the contact's LIVE incoming + /// request, and a stale one returns + /// [`ExternalAccountRegistration::Superseded`] without writing anything. + /// When the contact is established and the backend's rounds are inline + /// and atomic, the account and the contact's outbound-account marker are + /// written in the same store round, so the durable marker always names + /// the request the durable account was built from. Any other backend + /// gets no marker from this round; the caller stamps it once the account + /// is stored (dashpay/platform#4302 review). pub async fn register_external_contact_account( &self, our_identity_id: &Identifier, @@ -538,22 +551,8 @@ impl DashPayView<'_, B> { // typed `insert_funds` API after the upstream split. let managed = key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); - // Persist the registration BEFORE the in-memory inserts (same - // rationale as `register_contact_account`): without this round - // the account vanishes on relaunch and `send_payment` loses its - // xpub + derived-address state until the next sweep rebuilds it. - self.persister - .store(dashpay_account_registration_changeset( - account_type, - contact_xpub, - &managed, - )) - .map_err(|e| { - Transient(PlatformWalletError::InvalidIdentityData(format!( - "Failed to persist external contact account registration: {e}" - ))) - })?; - + #[cfg(test)] + registration_rendezvous::wait(&contact_identity_id).await; let mut wm = self.wallet_manager.write().await; let (wallet, info) = wm .get_wallet_mut_and_info_mut(&self.wallet_id) @@ -563,6 +562,81 @@ impl DashPayView<'_, B> { ))) })?; + // Re-check under the guard that serializes registrations: the early + // check above ran under a read guard that was released for the + // decrypt, and another registration may have built the account since. + let key = key_wallet::account::account_collection::DashpayAccountKey { + index: account_index, + user_identity_id: our_identity_id.to_buffer(), + friend_identity_id: contact_identity_id.to_buffer(), + }; + if info + .core_wallet + .accounts + .dashpay_external_accounts + .contains_key(&key) + { + return Ok(ExternalAccountRegistration::AlreadyExisted); + } + + // The payload must still be the contact's live request. The durable + // marker is bound to it in the same round as the account only where + // the backend keeps markers durably at all + // (`persists_outbound_account_markers`: an inline, atomic round that + // writes accounts and pools). A backend without that guarantee could + // reject or drop the account and pool yet keep the contact row, + // leaving the live request's marker beside the previous account — + // which a restart would trust. There the marker stays in memory only: + // the caller stamps it once this round succeeded, and every contact + // row written to that backend omits it (dashpay/platform#4302 review). + let binds_marker = self.persister.persists_outbound_account_markers(); + let bound_contact = match info + .identity_manager + .managed_identity(our_identity_id) + .and_then(|managed| managed.established_contact(&contact_identity_id)) + { + Some(contact) + if contact.incoming_request.encrypted_public_key != contact_encrypted_xpub => + { + tracing::warn!( + our_identity = %our_identity_id, + contact = %contact_identity_id, + "external account payload superseded by a rotation before registration; \ + nothing written, the sweep rebuilds from the live request" + ); + return Ok(ExternalAccountRegistration::Superseded); + } + Some(contact) if binds_marker => { + let mut bound = contact.clone(); + bound.external_account_reference = Some(contact.incoming_request.account_reference); + Some(bound) + } + Some(_) | None => None, + }; + + // Persist the registration BEFORE the in-memory inserts (same + // rationale as `register_contact_account`): without this round + // the account vanishes on relaunch and `send_payment` loses its + // xpub + derived-address state until the next sweep rebuilds it. + let mut changeset = + dashpay_account_registration_changeset(account_type, contact_xpub, &managed); + if let Some(bound) = &bound_contact { + let mut contacts = crate::changeset::ContactChangeSet::default(); + contacts.established.insert( + crate::changeset::SentContactRequestKey { + owner_id: *our_identity_id, + recipient_id: contact_identity_id, + }, + bound.clone(), + ); + changeset.contacts = Some(contacts); + } + self.persister.store(changeset).map_err(|e| { + Transient(PlatformWalletError::InvalidIdentityData(format!( + "Failed to persist external contact account registration: {e}" + ))) + })?; + // (a) Insert Account into the immutable wallet account collection so the // xpub is accessible by `send_payment`. wallet @@ -585,6 +659,16 @@ impl DashPayView<'_, B> { ))) })?; + if let Some(bound) = bound_contact { + if let Some(contact) = info + .identity_manager + .managed_identity_mut(our_identity_id) + .and_then(|managed| managed.established_contact_mut(&contact_identity_id)) + { + contact.external_account_reference = bound.external_account_reference; + } + } + tracing::info!( our_identity = %our_identity_id, contact = %contact_identity_id, @@ -594,3 +678,40 @@ impl DashPayView<'_, B> { Ok(ExternalAccountRegistration::Built) } } + +/// Test-only rendezvous between [`IdentityWallet::register_external_contact_account`]'s +/// early absence check and its write-guard acquisition, so a test can hold +/// several registrations of one contact at exactly that point and release +/// them together. Keyed by contact id, so registrations of every other +/// contact — and every other test — pass straight through. +#[cfg(test)] +pub(crate) mod registration_rendezvous { + use std::collections::BTreeMap; + use std::sync::{Arc, Mutex}; + + use dpp::prelude::Identifier; + use tokio::sync::Barrier; + + static BARRIERS: Mutex>> = Mutex::new(BTreeMap::new()); + + /// Hold `parties` registrations of `contact` at the rendezvous until all + /// of them have arrived. + pub(crate) fn install(contact: Identifier, parties: usize) { + BARRIERS + .lock() + .unwrap() + .insert(contact, Arc::new(Barrier::new(parties))); + } + + /// Stop holding registrations of `contact`. + pub(crate) fn remove(contact: &Identifier) { + BARRIERS.lock().unwrap().remove(contact); + } + + pub(crate) async fn wait(contact: &Identifier) { + let barrier = BARRIERS.lock().unwrap().get(contact).cloned(); + if let Some(barrier) = barrier { + barrier.wait().await; + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs index 2dfa4a54674..a448db29490 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -3932,6 +3932,8 @@ mod tests { wallet_id, asset_locks, persister, + sync_fault: Arc::new(std::sync::atomic::AtomicBool::new(false)), + durable_cursors: crate::changeset::DurableCursors::default(), broadcaster, sdk_writer: Arc::new(super::super::sdk_writer::SdkWriter::new(sdk)), dpns_operation_gate: Arc::new(tokio::sync::Mutex::new(())), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index a5a360951f5..6b4e18892ad 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -382,6 +382,19 @@ pub struct IdentityWallet { pub(crate) asset_locks: Arc>, /// Per-wallet persistence handle for queuing changesets. pub(crate) persister: crate::wallet::persister::WalletPersister, + /// The manager's persistence-fault latch (see + /// [`PlatformWalletManager::sync_fault_detected`](crate::PlatformWalletManager::sync_fault_detected)). + /// While it is set the wallet-event adapter holds the durable + /// `synced_height` back at the last fully persisted height, and a + /// direct cursor write from this side would advance it past rows that + /// never landed. The DashPay rescan reconcile consults it before it + /// stores a backfill record with a lowered cursor. + pub(crate) sync_fault: Arc, + /// The manager's shared record of each wallet's durable sync cursor, + /// also the lock that orders cursor writes between the wallet-event + /// adapter and the DashPay rescan reconcile. See + /// [`DurableCursors`](crate::changeset::DurableCursors). + pub(crate) durable_cursors: crate::changeset::DurableCursors, /// Broadcaster for DashPay payment transactions. Distinct from the /// asset-lock broadcaster — the asset-lock manager is always /// `SpvBroadcaster`-pinned, while this one picks the broadcaster @@ -423,6 +436,8 @@ impl Clone for IdentityWallet { wallet_id: self.wallet_id, asset_locks: Arc::clone(&self.asset_locks), persister: self.persister.clone(), + sync_fault: Arc::clone(&self.sync_fault), + durable_cursors: Arc::clone(&self.durable_cursors), broadcaster: Arc::clone(&self.broadcaster), sdk_writer: Arc::clone(&self.sdk_writer), dpns_operation_gate: Arc::clone(&self.dpns_operation_gate), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 994967f0d60..43307a56f09 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -86,38 +86,99 @@ impl DashPayView<'_, B> { /// `$coreHeightCreatedAt` across established receival contacts that haven't /// been rescanned yet — the filter manager (`dash-spv`) then re-downloads /// nothing it already has, re-matches the now-larger script set, and - /// re-requests the matching blocks. Each contact is recorded in - /// [`DashPayState::rescan_triggered`](crate::wallet::identity::DashPayState) so the recurring sweep does - /// not re-lower the height every pass (which would reset the in-flight - /// backfill and keep it from ever completing). The guard is in-memory, so a - /// relaunch — where `synced_height` is restored at its high-water — safely - /// re-triggers an interrupted backfill. + /// re-requests the matching blocks. + /// + /// # The guard, in memory and on disk + /// + /// Each contact the pass handles is recorded twice. Durably, in the + /// wallet's [`DashPayBackfillRecord`], keyed per contact on the checkpoint + /// it was covered from and written on the same persistence round as the + /// lowered cursor — that record is what decides whether a contact is + /// covered. And in memory, in + /// [`DashPayState::rescan_triggered`](crate::wallet::identity::DashPayState), + /// which only tracks which contacts this process has handled; a mark on + /// its own never counts as coverage, so a contact whose record write + /// failed, or whose checkpoint dropped below the height it was recorded + /// at, is re-armed through the candidate path. + /// + /// The durable half exists because the cursor this pass lowers is itself + /// durable: the host persists every `SyncHeightAdvanced` the engine emits + /// as the rescan climbs, so a relaunch restores `synced_height` *inside* + /// the climb, not at its high-water. With only the in-memory guard, every + /// fresh process rewound again — on a contact-heavy mainnet wallet a + /// re-walk of hundreds of thousands of filters per launch, never finishing + /// (dashpay/platform#4302). Now a contact the record covers at a + /// checkpoint no lower than the one it was covered from is treated as + /// covered: the scan resumes from the restored cursor with the contact's + /// addresses watched, which is exactly where the previous process left it. + /// A contact whose checkpoint has dropped below its recorded height, or + /// one the record never listed (registered since, or by a path that did + /// not record it), is re-armed as before — the record only ever suppresses + /// a rewind it can vouch for. + /// + /// Nothing is marked "complete" at trigger time: the record's extent + /// (`floor`, `rewound_from`) is descriptive, and completion is read off the + /// cursor ([`DashPayBackfillRecord::is_complete`]). An interrupted + /// backfill therefore resumes rather than restarts, and never reads as + /// finished while the cursor still sits below `rewound_from`. /// /// `synced_height` may regress here: that is safe because it is the /// filter-scan checkpoint, decoupled from the monotonic - /// `last_processed_height`, and every persisted sync cursor is monotonic-max - /// guarded, so a transient rewind cannot corrupt state or persist a lower - /// cursor. Only the **receival** account matters — `DashpayExternalAccount` - /// is outbound and never receives. Returns the floor the height was lowered - /// to, or `None`. + /// `last_processed_height`. The pinned engine refuses to certify a batch + /// scanned at the old checkpoint once the cursor sits below it, so an + /// in-flight batch cannot lift the rewound cursor back over the range it + /// never tested with the new addresses. Only the **receival** account + /// matters — `DashpayExternalAccount` is outbound and never receives. + /// Returns the floor the height was lowered to, or `None`. + /// + /// [`DashPayBackfillRecord`]: crate::changeset::DashPayBackfillRecord + /// [`DashPayBackfillRecord::is_complete`]: crate::changeset::DashPayBackfillRecord::is_complete pub async fn reconcile_dashpay_rescan(&self) -> Result, PlatformWalletError> { + use crate::changeset::{CoreChangeSet, PlatformWalletChangeSet}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + // The durable-cursor lock first, then the manager (see + // `DurableCursors`): held until this pass's store returns, so no + // adapter commit can interleave between deciding a cursor and storing + // it. + let mut durable_cursors = self.durable_cursors.lock().await; let mut wm = self.wallet_manager.write().await; let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { return Ok(None); }; + let durable = durable_cursors.get(&self.wallet_id).copied(); + // A cursor owed since an in-memory lowering is paid once the host + // has accepted a scan advance from the lowering's epoch or later: the + // host then tracks the scan that restarted from the lowered cursor. + // Writing the old height after that would only drag the host back + // below the scan (dashpay/platform#4302 device check). + if info + .dashpay_backfill + .settle_owed_cursor(durable.and_then(|durable| durable.advance_epoch)) + { + tracing::debug!( + wallet_id = %hex::encode(self.wallet_id), + "DashPay rescan: the host has caught up past the owed cursor; dropping the debt" + ); + } + // 0 means "scan from genesis / not yet started": a full historical + // scan is coming, so nothing is backfilled toward it — but every + // candidate is still handled, as forward-covered. Returning early + // instead left contacts the startup drain had just registered + // unrecorded, and the first sweep after the scan had climbed past + // their funding heights rewound mid-session for a range that scan had + // watched them through. No checkpoint is below 0, so the path below + // rewinds nothing here. let synced_height = info.core_wallet.synced_height(); - // 0 means "scan from genesis / not yet started" — already a full - // historical scan, nothing to backfill toward. - if synced_height == 0 { - return Ok(None); - } - // (owner, contact) pairs that have a receival account — we can only - // watch a contact's incoming addresses once its receival account exists. - let receival_pairs: Vec<(Identifier, Identifier)> = info + // (owner, contact, account_index) of every receival account — we can + // only watch a contact's incoming addresses once its receival account + // exists. Coverage is per account, not per pair: a second receiving + // account for a relationship derives its own xpub, and a scan that + // watched the first never tested the second's scripts. A set, because + // every coverage entry is checked against it under both locks. + let receival_accounts: std::collections::BTreeSet<(Identifier, Identifier, u32)> = info .core_wallet .accounts .dashpay_receival_accounts @@ -126,49 +187,113 @@ impl DashPayView<'_, B> { ( Identifier::from(k.user_identity_id), Identifier::from(k.friend_identity_id), + k.index, ) }) .collect(); - // Candidates: established receival contacts not yet rescanned this - // lifetime whose funding height is below our scan tip. The floor is the - // minimum funding height — one rewind covers them all (deeper-funded - // contacts are in the watch set, so the backfill matches them too). The + // Candidates: established receival contacts the durable record does + // not cover at their current checkpoint. The floor is the minimum + // funding height — one rewind covers them all (deeper-funded contacts + // are in the watch set, so the backfill matches them too). The // funding height is `min(outgoing, incoming)` of the pair: the channel - // is payable only once both requests exist, so the earlier of the two is - // the conservative-correct lower bound. + // is payable only once both requests exist, so the earlier of the two + // is the conservative-correct lower bound. + // + // A process-local `rescan_triggered` mark is never taken as coverage + // on its own: the mark may belong to a pass whose record write failed, + // or the contact's checkpoint may since have dropped below the height + // it was recorded at (an older reciprocal request learned later). + // Either way the range the scan never tested with this contact has to + // be rewound to, so an uncovered contact goes through the candidate + // path whether or not it is marked. let mut floor: Option = None; - let mut to_mark: Vec<(Identifier, Identifier)> = Vec::new(); - for (owner, contact) in receival_pairs { + // Receival accounts this pass handles, with the checkpoint each is + // covered from. + let mut to_mark: Vec<(Identifier, Identifier, u32, u32)> = Vec::new(); + // Contacts the durable record vouched for that are not yet marked in + // this process. + let mut restored: Vec<(Identifier, Identifier)> = Vec::new(); + for (owner, contact, account_index) in receival_accounts.iter().copied() { let Some(managed) = info.identity_manager.managed_identity(&owner) else { continue; }; - if managed.dashpay().rescan_triggered.contains(&contact) { - continue; - } let Some(established) = managed.dashpay().established_contacts().get(&contact) else { continue; }; - let funding = established + let checkpoint = established .outgoing_request .core_height_created_at .min(established.incoming_request.core_height_created_at); + // The previous round already rewound for this contact from a + // checkpoint no higher than today's, and the persisted cursor + // tracked that scan with its addresses watched, so nothing below + // the cursor is missing: resume, don't restart. + if info + .dashpay_backfill + .covers(&owner, &contact, account_index, checkpoint) + { + if !managed.dashpay().rescan_triggered.contains(&contact) + && !restored.contains(&(owner, contact)) + { + restored.push((owner, contact)); + } + continue; + } // Contacts funded below the tip need a backfill — their addresses // weren't watched when those blocks were first scanned. Contacts // funded at or after the tip are already covered by the ongoing // forward scan (their addresses are watched from establishment). - // EITHER way the contact is now handled, so mark it: once the + // EITHER way the contact is now handled, so record it: once the // forward pointer later climbs past a still-forward-covered // contact's funding height, the recurring sweep must NOT then // rewind to it and redundantly re-scan an already-scanned range. - if funding < synced_height { - floor = Some(floor.map_or(funding, |cur| cur.min(funding))); + if checkpoint < synced_height { + floor = Some(floor.map_or(checkpoint, |cur| cur.min(checkpoint))); + } + to_mark.push((owner, contact, account_index, checkpoint)); + } + + let restored_count = restored.len(); + for (owner, contact) in restored { + if let Some(managed) = info.identity_manager.managed_identity_mut(&owner) { + managed.dashpay_rescan_triggered_mut().insert(contact); } - to_mark.push((owner, contact)); + } + if restored_count > 0 { + tracing::info!( + wallet_id = %hex::encode(self.wallet_id), + contacts = restored_count, + synced_height, + floor = info.dashpay_backfill.floor, + rewound_from = info.dashpay_backfill.rewound_from, + pending = info.dashpay_backfill.is_pending(synced_height), + "DashPay rescan: persisted backfill record covers these contacts; resuming from the \ + restored cursor instead of rewinding again" + ); } + // No contact needs handling — but the record may still need a round: + // coverage for a receival account that no longer exists must go (its + // scripts stop being watched, so the coverage stops being true), and + // a cursor or extent owed by an earlier reset or failed round must + // reach the host before the process can die with it. Either one runs + // a record-only round below; otherwise there is nothing to store. if to_mark.is_empty() { - return Ok(None); + let record = &info.dashpay_backfill; + let prunes = record.covered().iter().any(|entry| { + !receival_accounts.contains(&(entry.owner, entry.contact, entry.account_index)) + }); + let owes = record.unpersisted_cursor.is_some() || record.unpersisted_extent.is_some(); + // While the fault latch is up, or after the backend refused a + // round, nothing is stored (see below); the debt waits for the + // next launch. A prune still runs, in memory: the session's + // coverage must stop vouching for an account that is gone. + let unstorable = + self.sync_fault.load(std::sync::atomic::Ordering::Relaxed) || record.store_refused; + if !(prunes || owes) || (unstorable && !prunes) { + return Ok(None); + } } // Lower the filter-scan checkpoint only when a contact is funded below @@ -177,21 +302,183 @@ impl DashPayView<'_, B> { // own header/birth floor, so no double-clamp here. if let Some(floor) = floor { info.core_wallet.update_synced_height(floor); + // Advances the engine emitted before this rewind must not be + // stored after it — see `RewindBarrier`. + info.rewind_barrier.arm(); } let triggered = to_mark.len(); - for (owner, contact) in to_mark { - if let Some(managed) = info.identity_manager.managed_identity_mut(&owner) { - managed.dashpay_rescan_triggered_mut().insert(contact); - } - } if let Some(floor) = floor { tracing::info!( wallet_id = %hex::encode(self.wallet_id), floor, + rewound_from = synced_height, contacts = triggered, "DashPay rescan: lowered SPV synced_height to backfill historical contact payments" ); } + + // Durable half of the guard. Every contact handled this pass is covered + // from its own checkpoint: a rewound one because the scan restarts at + // `floor <= checkpoint`, a forward-covered one because its addresses + // are watched from `synced_height <= checkpoint` on. Entries for + // accounts that no longer exist are dropped so the record tracks the + // live receival set. + // + // The record and the cursor it vouches for ride ONE round, and the + // record reaches memory only once that round is on disk: + // - the round carries the floor this pass rewound to, or failing that + // a cursor an earlier failed round still owes, so coverage is never + // stored beside a durable cursor the in-memory rewind left behind; + // - the pass's record is staged on a copy and installed, with the + // pass's marks, only when the store returns `Ok`. The cursor and + // extent the round owes are recorded on the live record BEFORE the + // store runs, so a store that fails transiently — or panics, which + // unwinds through here and releases the manager and cursor locks — + // leaves the previous coverage plus the debt for the next pass to + // carry, never coverage that did not reach disk; + // - a store the backend refuses outright (a non-transient failure) + // would be refused again, and retrying it would leave every later + // sweep rewinding to the same contacts. The record is marked + // `store_refused` and handled like the latched fault below for the + // rest of the session; + // - while the manager's persistence fault is latched the adapter is + // holding the durable cursor back at the last fully persisted + // height, and a cursor written from here could advance it past rows + // that never landed — so nothing is stored at all: the in-memory + // rewind still runs the rescan, and the next launch re-runs it, + // which is the pre-record behaviour. The latch is never cleared, so + // the pass's coverage is still installed in memory for the rest of + // the session, or every later sweep would rewind to the same + // contacts again. It keeps the debt: should anything store the + // record later, the owed cursor rides with it; + // - a backend that cannot hold the record durably gets none at all + // (`persists_backfill_coverage`): one whose `store` only buffers + // commits at a later `flush` that can still drop the round, and one + // that is not atomic can keep the coverage after rejecting the + // cursor it vouches for. The pass then runs in memory only, exactly + // the pre-record guard. + let mut staged = info.dashpay_backfill.clone(); + staged.retain(|owner, contact, account_index| { + receival_accounts.contains(&(*owner, *contact, account_index)) + }); + // This pass's extent, widened by one a failed earlier round still owes. + let extent = match ( + floor.map(|floor| (floor, synced_height)), + staged.unpersisted_extent.take(), + ) { + (Some((f1, r1)), Some((f2, r2))) => Some((f1.min(f2), r1.max(r2))), + (this_pass, owed) => this_pass.or(owed), + }; + match extent { + Some((extent_floor, rewound_from)) => { + staged.record_pass(rewound_from, Some(extent_floor), to_mark.iter().copied()) + } + None => staged.record_pass(synced_height, None, to_mark.iter().copied()), + }; + let owed = staged.unpersisted_cursor.take(); + staged.unpersisted_cursor_epoch = None; + let needed_cursor = match (floor, owed) { + (Some(floor), Some(owed)) => Some(floor.min(owed)), + (Some(floor), None) => Some(floor), + (None, owed) => owed, + }; + // Never write a cursor at or above the one the host already holds: + // coverage is safe beside any lower durable cursor, and writing a + // higher one would claim rows the adapter has not persisted yet. + let cursor_to_write = + needed_cursor.filter(|cursor| durable.is_none_or(|durable| *cursor < durable.height)); + let cursor_now = floor.unwrap_or(synced_height); + // The debt, recorded before the store so no way out of it loses it. + info.dashpay_backfill.unpersisted_cursor = + Some(needed_cursor.map_or(cursor_now, |cursor| cursor.min(cursor_now))); + info.dashpay_backfill.unpersisted_cursor_epoch = Some(info.rewind_barrier.epoch()); + info.dashpay_backfill.unpersisted_extent = extent; + /// What this pass's staged record becomes. + enum Settled { + /// Installed: stored durably, or a backend that keeps it in + /// memory only (and so carries no debt). + Installed { durable: bool }, + /// Not installed: a retryable failure; the next pass retries. + Retry, + /// Installed for the session only, beside the debt. + SessionOnly, + } + let faulted = self.sync_fault.load(std::sync::atomic::Ordering::Relaxed); + let settled = if !self.persister.persists_backfill_coverage() { + Settled::Installed { durable: false } + } else if faulted { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + "DashPay rescan: persistence fault latched; keeping this backfill's coverage \ + for the session only — the next launch re-runs it" + ); + Settled::SessionOnly + } else if staged.store_refused { + Settled::SessionOnly + } else { + let changeset = PlatformWalletChangeSet { + core: cursor_to_write.map(|cursor| CoreChangeSet { + synced_height: Some(cursor), + synced_height_is_rewind: true, + ..CoreChangeSet::default() + }), + dashpay_backfill: Some(staged.clone()), + ..PlatformWalletChangeSet::default() + }; + match self.persister.store(changeset) { + Ok(()) => Settled::Installed { durable: true }, + Err(error) if error.is_transient() => { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + error = %error, + "DashPay rescan: failed to persist the backfill record; the next pass \ + retries and the next launch re-runs this backfill" + ); + Settled::Retry + } + Err(error) => { + // Retrying a rejection that will keep failing would leave + // every later sweep rewinding to these contacts again. + // Stop storing for the session instead. + tracing::error!( + wallet_id = %hex::encode(self.wallet_id), + error = %error, + "DashPay rescan: the backend refused the backfill record; keeping its \ + coverage for the session only and storing no further record round — \ + the next launch re-runs this backfill" + ); + staged.store_refused = true; + Settled::SessionOnly + } + } + }; + match settled { + Settled::Installed { durable } => { + // In memory only (`durable` false), the staged record carries + // no debt: nothing will ever be stored for it to settle. + info.dashpay_backfill = staged; + if let Some(cursor) = cursor_to_write.filter(|_| durable) { + durable_cursors + .insert(self.wallet_id, crate::changeset::DurableCursor::at(cursor)); + } + } + Settled::Retry => return Ok(floor), + Settled::SessionOnly => { + // Coverage for the session, the debt kept beside it: should + // anything store the record later, the owed cursor rides + // with it. + let live = &info.dashpay_backfill; + staged.unpersisted_cursor = live.unpersisted_cursor; + staged.unpersisted_cursor_epoch = live.unpersisted_cursor_epoch; + staged.unpersisted_extent = live.unpersisted_extent; + info.dashpay_backfill = staged; + } + } + for (owner, contact, _, _) in &to_mark { + if let Some(managed) = info.identity_manager.managed_identity_mut(owner) { + managed.dashpay_rescan_triggered_mut().insert(*contact); + } + } Ok(floor) } @@ -1718,6 +2005,7 @@ mod tests { //! 3. Reconcile must be idempotent and never clobber an existing //! entry for the same txid. + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; @@ -1748,19 +2036,126 @@ mod tests { #[derive(Default)] struct RecordingPersister { stores: Mutex>, + /// When set, every `store` fails transiently (and is not recorded). + fail_stores: Mutex, + /// When set, every `store` is refused permanently (a `Fatal` + /// failure, not recorded); each refused attempt is counted. + refuse_stores: Mutex, + refused_attempts: std::sync::atomic::AtomicUsize, + /// When set, the next `store` panics — a backend that dies mid-write. + panic_next_store: Mutex, + /// When set, `store` only buffers and the backend commits at `flush` + /// (`store_commits_inline()` false). + buffered: Mutex, + /// When set, every `flush` fails. + fail_flushes: Mutex, + /// When set, the backend does not attest `ATOMIC_CHANGESETS`. + non_atomic: Mutex, + /// When set, the backend does not attest `ACCOUNT_ADDRESS_POOLS`: it + /// has no account writer (or only a no-op one). + no_account_writer: Mutex, + /// When set, a round carrying both a cursor and a backfill record + /// keeps the record and then fails on the cursor — the partial write + /// a non-atomic backend is allowed to leave. + partial_rounds: Mutex, + /// When set, a round carrying an account registration rejects the + /// account and pool but keeps the round's contact rows, then fails — + /// what `FFIPersister` does on a host without atomic rounds, which + /// carries on to later callbacks after one fails. + reject_account_rounds: Mutex, + /// When set, the next round carrying an account registration raises + /// `gate_entered` and then blocks until the sender side is used or + /// dropped — a slow backend held mid-write. + account_gate: Mutex>>, + gate_entered: std::sync::atomic::AtomicBool, } impl PlatformWalletPersistence for RecordingPersister { + fn store_commits_inline(&self) -> bool { + !*self.buffered.lock().unwrap() + } + + fn stores_dashpay_backfill(&self) -> bool { + true + } + + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + use crate::changeset::PersistenceCapabilities as C; + let atomic = if *self.non_atomic.lock().unwrap() { + C::NONE + } else { + C::ATOMIC_CHANGESETS + }; + let accounts = if *self.no_account_writer.lock().unwrap() { + C::NONE + } else { + C::ACCOUNT_ADDRESS_POOLS + }; + atomic.union(accounts) + } + fn store( &self, wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + if *self.fail_stores.lock().unwrap() { + return Err(PersistenceError::backend_with_kind( + crate::changeset::PersistenceErrorKind::Transient, + "injected store failure", + )); + } + if *self.refuse_stores.lock().unwrap() { + self.refused_attempts + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + return Err(PersistenceError::backend("injected store refusal")); + } + if std::mem::take(&mut *self.panic_next_store.lock().unwrap()) { + panic!("recording persister: injected store panic"); + } + if !changeset.account_registrations.is_empty() { + let gate = self.account_gate.lock().unwrap().take(); + if let Some(gate) = gate { + self.gate_entered + .store(true, std::sync::atomic::Ordering::SeqCst); + let _ = gate.recv(); + } + } + if *self.reject_account_rounds.lock().unwrap() + && !changeset.account_registrations.is_empty() + { + let kept = PlatformWalletChangeSet { + contacts: changeset.contacts, + ..PlatformWalletChangeSet::default() + }; + self.stores.lock().unwrap().push((wallet_id, kept)); + return Err(PersistenceError::backend("injected account write failure")); + } + if *self.partial_rounds.lock().unwrap() + && changeset.dashpay_backfill.is_some() + && changeset + .core + .as_ref() + .is_some_and(|core| core.synced_height.is_some()) + { + let kept = PlatformWalletChangeSet { + dashpay_backfill: changeset.dashpay_backfill, + ..PlatformWalletChangeSet::default() + }; + self.stores.lock().unwrap().push((wallet_id, kept)); + return Err(PersistenceError::backend("injected cursor write failure")); + } self.stores.lock().unwrap().push((wallet_id, changeset)); Ok(()) } fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + if *self.fail_flushes.lock().unwrap() { + return Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Transient, + "injected flush failure", + )); + } Ok(()) } @@ -2775,6 +3170,9 @@ mod tests { .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) .await .expect("register receival account"); + // An account on file that the record does not know (see + // `forget_recorded_coverage`). + forget_recorded_coverage(&manager, wallet_id, owner, contact).await; { let mut wm = iw.wallet_manager.write().await; @@ -2867,20 +3265,64 @@ mod tests { .managed_identity_mut(&owner) .expect("managed") .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + // Registration records its own coverage now. These fixtures exercise + // the reconcile's rewind path, which serves accounts persisted before + // the record existed (and hosts that do not store it), so model that: + // the account is on file, the record does not know it. + info.dashpay_backfill + .retain(|o, c, _| (o, c) != (&owner, &contact)); + } + + /// Drop `(owner, contact)` from the wallet's backfill record — the shape + /// of an account registered before the record existed, or by a host + /// that does not persist it — so the reconcile's own rewind path is what + /// a test exercises. + async fn forget_recorded_coverage( + manager: &Arc>, + wallet_id: WalletId, + owner: Identifier, + contact: Identifier, + ) { + let mut wm = manager.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .dashpay_backfill + .retain(|o, c, _| (o, c) != (&owner, &contact)); } + /// Move the wallet's cursor to `height` the way a scan does: in memory, + /// and — as the event adapter would once it commits the advance — on the + /// host, recorded in the manager's durable-cursor map. async fn set_synced_height( manager: &Arc>, wallet_id: WalletId, height: u32, ) { use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let mut durable = manager.durable_cursors.lock().await; let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); let mut wm = wallet.identity().wallet_manager.write().await; wm.get_wallet_info_mut(&wallet_id) .expect("info") .core_wallet .update_synced_height(height); + durable.insert(wallet_id, crate::changeset::DurableCursor::at(height)); + } + + /// The wallet's [`RewindBarrier`](crate::changeset::core_bridge::RewindBarrier) + /// epoch: how many times its cursor was lowered in memory. + async fn rewind_epoch( + manager: &Arc>, + wallet_id: WalletId, + ) -> u64 { + manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .expect("info") + .rewind_barrier + .epoch() } async fn synced_height( @@ -3018,8 +3460,10 @@ mod tests { } /// `synced_height == 0` means "scan from genesis / not started" — already a - /// full historical scan, so the rescan is a no-op (the masking path the spec - /// warns about). + /// full historical scan, so the rescan rewinds nothing (the masking path the + /// spec warns about). The contact is still recorded as forward-covered, so + /// the sweep does not rewind for it once that scan has climbed past its + /// funding height. #[tokio::test] async fn rescan_is_a_noop_when_synced_height_is_zero() { let (manager, persister, wallet_id) = make_wallet().await; @@ -3041,6 +3485,3926 @@ mod tests { "synced_height 0 -> no rescan" ); assert_eq!(synced_height(&manager, wallet_id).await, 0); + let wm = manager.wallet_manager.read().await; + assert!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill + .covers(&owner, &contact, 0, 100), + "recorded as forward-covered by the coming scan" + ); + } + + /// The persisted half of the rescan guard, captured from a live manager + /// the way a host stores it: the wallet, its managed info (accounts and + /// cursor), its identities with the in-memory marks cleared, and the + /// backfill record from the last round the manager stored. + struct ReloadPersister { + wallet: key_wallet::Wallet, + managed: key_wallet::wallet::ManagedWalletInfo, + identities: std::collections::BTreeMap< + crate::wallet::identity::RegistrationIndex, + crate::wallet::identity::ManagedIdentity, + >, + backfill: crate::changeset::DashPayBackfillRecord, + stores: Mutex>, + } + + impl PlatformWalletPersistence for ReloadPersister { + fn store_commits_inline(&self) -> bool { + true + } + + fn stores_dashpay_backfill(&self) -> bool { + true + } + + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS + .union(crate::changeset::PersistenceCapabilities::ACCOUNT_ADDRESS_POOLS) + } + + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.stores.lock().unwrap().push((wallet_id, changeset)); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + let wallet_id = self.wallet.compute_wallet_id(); + let mut wallet_identities = std::collections::BTreeMap::new(); + wallet_identities.insert(wallet_id, self.identities.clone()); + let mut wallets = std::collections::BTreeMap::new(); + wallets.insert( + wallet_id, + crate::changeset::ClientWalletStartState { + wallet: self.wallet.clone(), + wallet_info: self.managed.clone(), + identity_manager: crate::changeset::IdentityManagerStartState { + wallet_identities, + ..Default::default() + }, + unused_asset_locks: std::collections::BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), + dashpay_backfill: self.backfill.clone(), + }, + ); + Ok(ClientStartState { + wallets, + ..Default::default() + }) + } + } + + /// Snapshot `manager`'s wallet the way a relaunch would restore it: the + /// persisted cursor at `persisted_synced_height`, every in-memory + /// rescan mark gone, and the backfill record as the host last stored it + /// (`backfill`, or the record the manager holds when `None`). + async fn reload_snapshot( + manager: &Arc>, + wallet_id: WalletId, + owner: Identifier, + persisted_synced_height: u32, + backfill: Option, + ) -> ReloadPersister { + let wm = manager.wallet_manager.read().await; + let wallet = wm.get_wallet(&wallet_id).expect("wallet").clone(); + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let mut managed = info.core_wallet.clone(); + managed.metadata.synced_height = persisted_synced_height; + let mut identity = info + .identity_manager + .managed_identity(&owner) + .expect("owner identity") + .clone(); + identity.dashpay_rescan_triggered_mut().clear(); + let mut identities = std::collections::BTreeMap::new(); + identities.insert(0, identity); + ReloadPersister { + wallet, + managed, + identities, + backfill: backfill.unwrap_or_else(|| info.dashpay_backfill.clone()), + stores: Mutex::default(), + } + } + + /// Bring a manager up from `persister` and hand back the restored wallet. + async fn reload( + persister: ReloadPersister, + ) -> ( + Arc>, + Arc, + WalletId, + ) { + let wallet_id = persister.wallet.compute_wallet_id(); + let persister = Arc::new(persister); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::clone(&persister), + handler, + )); + manager + .load_from_persistor() + .await + .expect("the snapshot must load"); + (manager, persister, wallet_id) + } + + /// The backfill record the manager last stored, from the newest round + /// that carried one. + fn last_stored_record( + stores: &[(WalletId, PlatformWalletChangeSet)], + ) -> Option<&PlatformWalletChangeSet> { + stores + .iter() + .rev() + .map(|(_, cs)| cs) + .find(|cs| cs.dashpay_backfill.is_some()) + } + + /// dashpay/platform#4302. The cursor a rewind lowers is durable — the + /// host persists every height the rescan climbs through — so a relaunch + /// restores it *inside* the climb. With only the in-memory guard every + /// fresh process rewound again, forever. The record the rewind stored + /// on its own round must make the next process resume from the restored + /// cursor instead; and it is the record doing that, not anything the + /// reload happens to carry: the same snapshot without it rewinds again. + #[tokio::test] + async fn rescan_resumes_from_the_persisted_cursor_after_a_reload_instead_of_rewinding_again() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + persister.stores.lock().unwrap().clear(); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("first pass"), + Some(100) + ); + + // The record and the lowered cursor ride ONE round: a host that + // stores the record has stored the cursor it vouches for. + { + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the rewind must persist its record"); + assert_eq!( + round.core.as_ref().and_then(|core| core.synced_height), + Some(100), + "the lowered cursor must be on the same round as the record" + ); + let record = round.dashpay_backfill.as_ref().unwrap(); + assert_eq!((record.floor, record.rewound_from), (100, 1_000)); + assert_eq!(record.covered_from(&owner, &contact, 0), Some(100)); + assert!(record.is_pending(100)); + assert!( + !record.is_complete(999), + "not complete until the scan climbs past 1000" + ); + assert!(record.is_complete(1_000)); + } + + // The process dies while the rescan is climbing: the host persisted + // the cursor at 400. + let snapshot = reload_snapshot(&manager, wallet_id, owner, 400, None).await; + let (restarted, restarted_persister, _) = reload(snapshot).await; + let restored = restarted + .get_wallet(&wallet_id) + .await + .expect("restored wallet"); + assert_eq!( + restored + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("pass after reload"), + None, + "a contact the persisted record covers must not be rewound for again" + ); + { + let wm = restarted.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!( + info.core_wallet.synced_height(), + 400, + "the scan resumes from the restored cursor, mid-climb" + ); + assert!( + info.identity_manager + .managed_identity(&owner) + .unwrap() + .dashpay() + .rescan_triggered + .contains(&contact), + "the record re-seeds the in-memory guard for this process" + ); + assert!(info.dashpay_backfill.is_pending(400)); + } + assert!( + restarted_persister + .stores + .lock() + .unwrap() + .iter() + .all(|(_, cs)| cs.dashpay_backfill.is_none()), + "nothing changed, so nothing is re-stored" + ); + + // The climb completes: still no rewind, and the record reads complete. + { + let mut wm = restarted.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.core_wallet.update_synced_height(1_000); + } + assert_eq!( + restored + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("pass at completion"), + None + ); + { + let wm = restarted.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert!(info.dashpay_backfill.is_complete(1_000)); + assert_eq!(info.core_wallet.synced_height(), 1_000); + } + + // Mutation check: the identical snapshot with the record withheld — + // the in-memory-only behaviour — rewinds again. Restoring that + // behaviour in the reconcile fails the assertions above, not this one. + let without = reload_snapshot( + &manager, + wallet_id, + owner, + 400, + Some(crate::changeset::DashPayBackfillRecord::default()), + ) + .await; + let (bare, _, _) = reload(without).await; + assert_eq!( + bare.get_wallet(&wallet_id) + .await + .expect("wallet") + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("pass without a record"), + Some(100), + "without the persisted record the reload rewinds again — the defect" + ); + } + + /// A contact's checkpoint can drop after it was covered: the reciprocal + /// request turns out to be older than ours. The record covers it only + /// from the height it was recorded at, so the reload must still rewind + /// to the new, lower checkpoint — and the record's floor follows it + /// while its climb target stays. + #[tokio::test] + async fn rescan_reload_rewinds_for_a_contact_whose_checkpoint_dropped_below_its_covered_height() + { + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("first pass"), + Some(100) + ); + + // Before the relaunch the persisted contact state learned an older + // incoming request (50), while the record still says "covered from + // 100". + let mut snapshot = reload_snapshot(&manager, wallet_id, owner, 400, None).await; + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], 50, 0); + snapshot + .identities + .get_mut(&0) + .unwrap() + .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + assert_eq!( + snapshot.backfill.covered_from(&owner, &contact, 0), + Some(100) + ); + + let (restarted, restarted_persister, _) = reload(snapshot).await; + assert_eq!( + restarted + .get_wallet(&wallet_id) + .await + .expect("wallet") + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("pass after reload"), + Some(50), + "a checkpoint below the recorded height re-arms the contact" + ); + let wm = restarted.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!(info.core_wallet.synced_height(), 50); + assert_eq!( + info.dashpay_backfill.covered_from(&owner, &contact, 0), + Some(50) + ); + assert_eq!( + ( + info.dashpay_backfill.floor, + info.dashpay_backfill.rewound_from + ), + (50, 1_000), + "the floor lowers, the climb target stays" + ); + let stores = restarted_persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the deeper rewind persists its record"); + assert_eq!( + round.core.as_ref().and_then(|core| core.synced_height), + Some(50) + ); + } + + /// The record covers exactly the contacts whose rewind it was written + /// for. A receival account it never listed — registered after the record + /// was stored, or established on another device and only now built here + /// — is re-armed on reload regardless of how far the scan has climbed: + /// the scan the record vouches for never watched that contact. + #[tokio::test] + async fn rescan_reload_rearms_a_receival_contact_the_record_never_listed() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let covered = Identifier::from([0xBB; 32]); + let newcomer = Identifier::from([0xCC; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, covered, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("first pass"), + Some(100) + ); + let record = { + let wm = manager.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .unwrap() + .dashpay_backfill + .clone() + }; + assert_eq!(record.covered().len(), 1); + + // The newcomer's account exists on this device by the time of the + // relaunch, but the record on disk predates it. + establish_receival_contact(&manager, &persister, wallet_id, owner, newcomer, 300, 300) + .await; + let snapshot = reload_snapshot(&manager, wallet_id, owner, 400, Some(record)).await; + let (restarted, _, _) = reload(snapshot).await; + assert_eq!( + restarted + .get_wallet(&wallet_id) + .await + .expect("wallet") + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("pass after reload"), + Some(300), + "an unlisted receival contact is never treated as covered" + ); + let wm = restarted.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!(info.core_wallet.synced_height(), 300); + assert_eq!( + info.dashpay_backfill.covered_from(&owner, &covered, 0), + Some(100) + ); + assert_eq!( + info.dashpay_backfill.covered_from(&owner, &newcomer, 0), + Some(300) + ); + assert_eq!( + ( + info.dashpay_backfill.floor, + info.dashpay_backfill.rewound_from + ), + (100, 1_000) + ); + } + + /// Coverage is per receival account, not per relationship: a second + /// receiving account for an already-covered pair derives its own xpub, + /// and the scan that covered the first never tested its scripts. It is + /// rewound for, not waved through on the first account's entry. + #[tokio::test] + async fn a_second_receival_account_for_a_covered_pair_is_rewound_not_inherited() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let dashpay = wallet.identity().dashpay(); + assert_eq!( + dashpay + .reconcile_dashpay_rescan() + .await + .expect("first pass"), + Some(100) + ); + assert_eq!( + rewind_epoch(&manager, wallet_id).await, + 1, + "the rewind arms the barrier" + ); + set_synced_height(&manager, wallet_id, 1_000).await; + assert_eq!( + dashpay.reconcile_dashpay_rescan().await.expect("steady"), + None, + "account 0 is covered" + ); + + dashpay + .register_contact_account( + &owner, + &contact, + 1, + test_receiving_xpub(&owner, &Identifier::from([0xDD; 32])), + ) + .await + .expect("register account 1"); + assert_eq!( + dashpay + .reconcile_dashpay_rescan() + .await + .expect("pass after account 1"), + Some(100), + "account 1 never inherits account 0's coverage" + ); + let wm = manager.wallet_manager.read().await; + let record = &wm + .get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill; + assert_eq!(record.covered_from(&owner, &contact, 0), Some(100)); + assert_eq!(record.covered_from(&owner, &contact, 1), Some(100)); + let stores = persister.stores.lock().unwrap(); + assert_eq!( + last_stored_record(&stores) + .and_then(|cs| cs.dashpay_backfill.as_ref()) + .expect("a record round") + .covered() + .len(), + 2, + "both accounts reach the host" + ); + } + + /// A contact's compact receiving xpub encrypted under `shared_key` — the + /// wire shape a contact sends us — so `register_external_contact_account` + /// can be driven without a signer. + fn encrypted_contact_xpub( + owner: &Identifier, + contact: &Identifier, + shared_key: &[u8; 32], + ) -> Vec { + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) + .expect("mnemonic") + .to_seed(""); + let w = key_wallet::wallet::Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("seed wallet"); + let compact = crate::wallet::identity::crypto::dip14::derive_contact_xpub( + &w, + Network::Testnet, + 0, + owner, + contact, + ) + .expect("derive a valid compact xpub") + .compact + .to_bytes(); + platform_encryption::encrypt_extended_public_key(shared_key, &[0x11u8; 16], &compact) + } + + /// The outbound `DashpayExternalAccount` is watch-only and never + /// receives, so registering (or rebuilding) it must not move the + /// filter-scan cursor. Every cold start whose host does not persist + /// `external_account_reference` tears the account down and rebuilds it; + /// on a tree where that rebuild also rewound the cursor it re-walked + /// every filter from the earliest contact's request height per launch + /// (dashpay/platform#4302, the topple relaunch). Pin the invariant so a + /// registration-time rewind (#4740) cannot pick the outbound account up. + #[tokio::test] + async fn external_account_registration_leaves_the_scan_cursor_untouched() { + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + let shared_key = [0x55u8; 32]; + let encrypted = encrypted_contact_xpub(&owner, &contact, &shared_key); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + let outgoing = + ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 1_226_329, 0); + let incoming = + ContactRequest::new(contact, owner, 0, 0, 0, encrypted.clone(), 1_226_329, 0); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + info.core_wallet.update_synced_height(1_560_740); + } + let registration = iw + .dashpay() + .register_external_contact_account( + &owner, + &bare_identity(contact.to_buffer()), + &encrypted, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register the outbound account"); + assert_eq!( + registration, + crate::wallet::identity::network::contacts::ExternalAccountRegistration::Built + ); + assert_eq!( + synced_height(&manager, wallet_id).await, + 1_560_740, + "an outbound account never receives: building it must not rewind the scan" + ); + } + + /// The observed relaunch, end to end (dashpay/platform#4302, topple on + /// int23): a restore with established contacts, both accounts persisted, + /// the cursor at the tip and a record covering every contact. With the + /// outbound-account marker restored, the contact pass finds nothing to + /// rebuild; without it (a host that does not carry the marker) the + /// rebuild still leaves the cursor alone; and the reconcile pass after + /// either leaves the cursor at the tip. + #[tokio::test] + async fn a_cold_start_with_persisted_accounts_and_record_leaves_the_cursor_at_the_tip() { + use crate::wallet::identity::network::contact_requests::external_account_needs_rebuild; + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + use key_wallet::account::account_collection::DashpayAccountKey; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contacts: Vec = (1..=8u8).map(|b| Identifier::from([b; 32])).collect(); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + for (i, contact) in contacts.iter().enumerate() { + let height = 1_226_329 + (i as u32) * 5_000; + let outgoing = + ContactRequest::new(owner, *contact, 0, 0, 0, vec![0u8; 96], height, 0); + // The incoming request carries the ciphertext the outbound + // account is built from, so the marker stamp accepts it. + let ciphertext = encrypted_contact_xpub(&owner, contact, &[0x55u8; 32]); + let incoming = ContactRequest::new(*contact, owner, 0, 0, 7, ciphertext, height, 0); + managed.apply_established_contact(EstablishedContact::new( + *contact, outgoing, incoming, + )); + } + } + // First session: the scan is at the tip when the contacts arrive. + // Registration never moves the cursor here; the reconcile pass then + // rewinds once to the earliest request height and records every + // contact, every outbound registration stamps the marker, and the scan + // climbs back to the tip. + set_synced_height(&manager, wallet_id, 1_560_740).await; + let shared_key = [0x55u8; 32]; + for contact in &contacts { + iw.dashpay() + .register_contact_account(&owner, contact, 0, test_receiving_xpub(&owner, contact)) + .await + .expect("register receival"); + let encrypted = encrypted_contact_xpub(&owner, contact, &shared_key); + iw.dashpay() + .register_external_contact_account( + &owner, + &bare_identity(contact.to_buffer()), + &encrypted, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register outbound"); + iw.dashpay() + .note_external_account_registered(&owner, contact, &encrypted) + .await; + } + assert_eq!( + synced_height(&manager, wallet_id).await, + 1_560_740, + "registration alone never moves the cursor" + ); + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("first reconcile"), + Some(1_226_329) + ); + set_synced_height(&manager, wallet_id, 1_560_740).await; + + // The host persisted everything: accounts, contacts with the marker, + // the record, the cursor at the tip. Cold start. + let snapshot = reload_snapshot(&manager, wallet_id, owner, 1_560_740, None).await; + let (restarted, _, _) = reload(snapshot).await; + { + let wm = restarted.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let managed = info.identity_manager.managed_identity(&owner).unwrap(); + for contact in &contacts { + let established = managed.dashpay().established_contacts()[contact].clone(); + assert_eq!(established.external_account_reference, Some(7)); + assert!( + !external_account_needs_rebuild(&established, true), + "a restored marker must not read as a rotation" + ); + } + assert!( + crate::wallet::identity::network::dashpay_view::DashPayView::< + crate::broadcaster::SpvBroadcaster, + >::collect_account_build_candidates(info, &owner) + .is_empty(), + "both accounts persisted: the contact pass has nothing to build" + ); + } + let restored = restarted.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + restored + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + None + ); + assert_eq!(synced_height_of(&restarted, wallet_id).await, 1_560_740); + + // A host that did not carry the marker: the pass tears the outbound + // accounts down and the drain rebuilds them — and even that must not + // move the cursor any more. + let mut bare = reload_snapshot(&manager, wallet_id, owner, 1_560_740, None).await; + for contact in &contacts { + let established = bare.identities[&0].dashpay().established_contacts()[contact].clone(); + let mut stripped = established.clone(); + stripped.external_account_reference = None; + bare.identities + .get_mut(&0) + .unwrap() + .apply_established_contact(stripped); + assert!(external_account_needs_rebuild( + &bare.identities[&0].dashpay().established_contacts()[contact], + true + )); + } + let (restarted, _, _) = reload(bare).await; + let restored = restarted.get_wallet(&wallet_id).await.expect("wallet"); + for contact in &contacts { + { + let mut wm = restarted.wallet_manager.write().await; + let (wallet, info) = wm.get_wallet_mut_and_info_mut(&wallet_id).unwrap(); + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + wallet.accounts.dashpay_external_accounts.remove(&key); + info.core_wallet + .accounts + .dashpay_external_accounts + .remove(&key); + } + let encrypted = encrypted_contact_xpub(&owner, contact, &shared_key); + assert_eq!( + restored + .identity() + .dashpay() + .register_external_contact_account( + &owner, + &bare_identity(contact.to_buffer()), + &encrypted, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("rebuild outbound"), + crate::wallet::identity::network::contacts::ExternalAccountRegistration::Built + ); + } + assert_eq!( + restored + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + None + ); + assert_eq!( + synced_height_of(&restarted, wallet_id).await, + 1_560_740, + "rebuilding every outbound account on a cold start must leave the cursor at the tip" + ); + } + + /// Contacts discovered only after the scan has passed their request + /// heights. Registration never moves the cursor on this tree; the + /// reconcile pass then rewinds exactly once and writes the record with + /// the real pair — the floor and the cursor it rewound from — on the + /// round that lowers the cursor. A kill mid-climb then resumes from the + /// persisted cursor: the drain's re-registrations are no-ops, the pass + /// has nothing to build, the reconcile does not rewind, and the record + /// still reads pending with its pair intact. + #[tokio::test] + async fn late_discovered_contacts_rewind_once_and_a_mid_climb_kill_resumes_from_the_cursor() { + use crate::wallet::identity::network::contact_requests::external_account_needs_rebuild; + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contacts: Vec = (1..=8u8).map(|b| Identifier::from([b; 32])).collect(); + let shared_key = [0x55u8; 32]; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + for (i, contact) in contacts.iter().enumerate() { + let height = 1_226_329 + (i as u32) * 5_000; + let outgoing = + ContactRequest::new(owner, *contact, 0, 0, 0, vec![0u8; 96], height, 0); + let ciphertext = encrypted_contact_xpub(&owner, contact, &shared_key); + let incoming = ContactRequest::new(*contact, owner, 0, 0, 7, ciphertext, height, 0); + managed.apply_established_contact(EstablishedContact::new( + *contact, outgoing, incoming, + )); + } + } + // The scan is already at the tip when the contacts are discovered. + set_synced_height(&manager, wallet_id, 1_560_731).await; + persister.stores.lock().unwrap().clear(); + + let mut lowering_rounds = 0usize; + for contact in &contacts { + let before = persister.stores.lock().unwrap().len(); + iw.dashpay() + .register_contact_account(&owner, contact, 0, test_receiving_xpub(&owner, contact)) + .await + .expect("register receival"); + let encrypted = encrypted_contact_xpub(&owner, contact, &shared_key); + iw.dashpay() + .register_external_contact_account( + &owner, + &bare_identity(contact.to_buffer()), + &encrypted, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register outbound"); + iw.dashpay() + .note_external_account_registered(&owner, contact, &encrypted) + .await; + let stores = persister.stores.lock().unwrap(); + lowering_rounds += stores[before..] + .iter() + .filter(|(_, cs)| { + cs.core + .as_ref() + .and_then(|core| core.synced_height) + .is_some() + }) + .count(); + } + assert_eq!( + lowering_rounds, 0, + "registration never moves the cursor on this tree" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 1_560_731); + // The reconcile pass is what rewinds: once, to the earliest request + // height, recording every contact on the round that lowers the cursor. + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + Some(1_226_329) + ); + assert_eq!(synced_height(&manager, wallet_id).await, 1_226_329); + { + let stores = persister.stores.lock().unwrap(); + let lowering = stores + .iter() + .find(|(_, cs)| { + cs.core + .as_ref() + .and_then(|core| core.synced_height) + .is_some() + }) + .map(|(_, cs)| cs) + .expect("the lowering round"); + assert_eq!( + lowering.core.as_ref().and_then(|core| core.synced_height), + Some(1_226_329) + ); + let record = lowering + .dashpay_backfill + .as_ref() + .expect("the lowering round carries the record"); + assert_eq!( + (record.floor, record.rewound_from), + (1_226_329, 1_560_731), + "the real pair: the floor and the cursor it rewound from" + ); + // The last record written covers every contact, pair unchanged. + let last = last_stored_record(&stores) + .unwrap() + .dashpay_backfill + .as_ref() + .unwrap(); + assert_eq!((last.floor, last.rewound_from), (1_226_329, 1_560_731)); + for contact in &contacts { + assert!(last.covered_from(&owner, contact, 0).is_some()); + } + } + + // Killed mid-climb: the host persisted the cursor at 1_400_000. + let snapshot = reload_snapshot(&manager, wallet_id, owner, 1_400_000, None).await; + let (restarted, restarted_persister, _) = reload(snapshot).await; + let restored = restarted.get_wallet(&wallet_id).await.expect("wallet"); + { + let wm = restarted.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let managed = info.identity_manager.managed_identity(&owner).unwrap(); + for contact in &contacts { + assert!(!external_account_needs_rebuild( + &managed.dashpay().established_contacts()[contact], + true + )); + } + assert!( + crate::wallet::identity::network::dashpay_view::DashPayView::< + crate::broadcaster::SpvBroadcaster, + >::collect_account_build_candidates(info, &owner) + .is_empty() + ); + assert!(info.dashpay_backfill.is_pending(1_400_000)); + } + // A drain that re-registers anyway is a no-op on accounts on file. + for contact in &contacts { + restored + .identity() + .dashpay() + .register_contact_account(&owner, contact, 0, test_receiving_xpub(&owner, contact)) + .await + .expect("idempotent re-registration"); + } + assert_eq!( + restored + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + None + ); + assert_eq!( + synced_height_of(&restarted, wallet_id).await, + 1_400_000, + "the climb resumes from the persisted cursor" + ); + assert!( + restarted_persister + .stores + .lock() + .unwrap() + .iter() + .all(|(_, cs)| cs + .core + .as_ref() + .and_then(|core| core.synced_height) + .is_none()), + "nothing lowers the cursor on the cold start" + ); + let wm = restarted.wallet_manager.read().await; + let record = &wm.get_wallet_info(&wallet_id).unwrap().dashpay_backfill; + assert_eq!((record.floor, record.rewound_from), (1_226_329, 1_560_731)); + assert!(record.is_pending(1_400_000)); + assert!(record.is_complete(1_560_731)); + } + + /// key-wallet's account-add API rewinds the cursor to just below wallet + /// birth without emitting or persisting anything. That lowering is owed + /// to the host like any other in-memory rewind: a later record-only + /// round (a contact forward-covered at the lowered cursor) must carry it, + /// or the host keeps its high-water beside the new coverage and the next + /// launch skips the rescan (dashpay/platform#4302 review). + #[tokio::test] + async fn an_account_add_rewind_is_owed_to_the_next_record_round() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + set_synced_height(&manager, wallet_id, 1_000).await; + { + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.core_wallet.metadata.birth_height = 101; + ManagedAccountOperations::add_managed_account_from_xpub( + info, + AccountType::Standard { + index: 9, + standard_account_type: StandardAccountType::BIP44Account, + }, + test_receiving_xpub(&owner, &Identifier::from([0xEE; 32])), + ) + .expect("add account"); + assert_eq!( + info.core_wallet.metadata.synced_height, 100, + "rewound to birth - 1" + ); + assert_eq!(info.dashpay_backfill.unpersisted_cursor, Some(100)); + assert_eq!(info.rewind_barrier.epoch(), 1, "and the barrier is armed"); + } + + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 200, 200).await; + persister.stores.lock().unwrap().clear(); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + None, + "forward-covered at the lowered cursor" + ); + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the record is stored"); + let core = round + .core + .as_ref() + .expect("the owed cursor rides the round"); + assert_eq!(core.synced_height, Some(100)); + assert!(core.synced_height_is_rewind); + } + + /// A fresh manager recreating, born at 101, the wallet a host holds at + /// cursor 1000 with `backfill` on its row. + async fn recreate_on_host( + backfill: crate::changeset::DashPayBackfillRecord, + ) -> ( + Arc>, + Arc, + Arc, + WalletId, + ) { + recreate_on_host_at(backfill, 1_000, None).await + } + + /// [`recreate_on_host`] with the host row at `host_cursor`, and + /// optionally a `DurableCursors` entry already under the wallet's id + /// before the registration — what a same-id predecessor's commit, + /// accepted after the registration read its snapshot, leaves behind. + async fn recreate_on_host_at( + backfill: crate::changeset::DashPayBackfillRecord, + host_cursor: u32, + leftover_entry: Option, + ) -> ( + Arc>, + Arc, + Arc, + WalletId, + ) { + let (donor, _donor_persister, wallet_id) = make_wallet().await; + let (wallet, mut managed) = { + let wm = donor.wallet_manager.read().await; + ( + wm.get_wallet(&wallet_id).expect("wallet").clone(), + wm.get_wallet_info(&wallet_id) + .expect("info") + .core_wallet + .clone(), + ) + }; + managed.metadata.synced_height = host_cursor; + let host = Arc::new(ReloadPersister { + wallet, + managed, + identities: std::collections::BTreeMap::new(), + backfill, + stores: Mutex::default(), + }); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&host), handler)); + if let Some(entry) = leftover_entry { + manager + .durable_cursors + .lock() + .await + .insert(wallet_id, entry); + } + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) + .expect("valid mnemonic") + .to_seed(""); + let recreated = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed, + WalletAccountCreationOptions::Default, + Some(101), + ) + .await + .expect("recreate"); + assert_eq!(recreated.wallet_id(), wallet_id); + (manager, host, recreated, wallet_id) + } + + /// Register `(owner, contact)`'s receival account on a recreated wallet + /// and establish the contact at `checkpoint`. + async fn establish_on_recreated( + manager: &Arc>, + host: &Arc, + wallet: &Arc, + owner: Identifier, + contact: Identifier, + checkpoint: u32, + ) { + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + let wallet_id = wallet.wallet_id(); + wallet + .identity() + .dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register receival account"); + let p = WalletPersister::new(wallet_id, Arc::clone(host) as _); + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], checkpoint, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], checkpoint, 0); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + } + + /// A fresh manager recreating a wallet the host already holds (the same + /// seed added again) builds a cursor below the host's — registration + /// writes no core cursor and the host keeps its row's `syncedHeight`. + /// The durable cursor is seeded from what the host holds, and the lower + /// built cursor is owed like any in-memory reset: the first record round + /// carries it, so a contact forward-covered at the built cursor is never + /// stored beside the host's higher one, and a reload rewinds for nothing + /// it should not (dashpay/platform#4302 review). + #[tokio::test] + async fn recreating_a_persisted_wallet_owes_its_lower_cursor_to_the_host() { + let (manager, host, recreated, wallet_id) = recreate_on_host(Default::default()).await; + { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!(info.core_wallet.metadata.synced_height, 100); + assert_eq!( + info.dashpay_backfill.unpersisted_cursor, + Some(100), + "the lower built cursor is owed to the host" + ); + } + assert_eq!( + manager + .durable_cursors + .lock() + .await + .get(&wallet_id) + .map(|cursor| cursor.height), + Some(1_000), + "the durable cursor is the host's, not the built one" + ); + + // A contact forward-covered at the built cursor. + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_on_recreated(&manager, &host, &recreated, owner, contact, 200).await; + host.stores.lock().unwrap().clear(); + assert_eq!( + recreated + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + None, + "forward-covered at the built cursor" + ); + let stores = host.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the record is stored"); + let core = round + .core + .as_ref() + .expect("the owed reset rides the record round"); + assert_eq!(core.synced_height, Some(100)); + assert!(core.synced_height_is_rewind); + assert_eq!( + round + .dashpay_backfill + .as_ref() + .unwrap() + .covered_from(&owner, &contact, 0), + Some(200) + ); + } + + /// Coverage for a receival account the recreated wallet does not hold is + /// not coverage: the successor's scan does not watch that account's + /// scripts, and the account-generation guard only stops batches still in + /// flight, not an advance already committed. So recreation keeps the + /// host's record only for the accounts it holds — none, from the seed — + /// and the account is rewound for when it is registered, even though the + /// host's record said it was covered (dashpay/platform#4302 review). + #[tokio::test] + async fn a_recreated_wallet_rearms_coverage_for_receival_accounts_it_does_not_hold() { + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let mut host_record = crate::changeset::DashPayBackfillRecord::default(); + host_record.record_pass(900, Some(200), [(owner, contact, 0, 200)]); + let (manager, host, recreated, wallet_id) = recreate_on_host(host_record.clone()).await; + { + let wm = manager.wallet_manager.read().await; + let record = &wm + .get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill; + assert!( + record.is_empty(), + "no receival account is held, so nothing is covered" + ); + assert!(!record.has_extent()); + assert_eq!(record.unpersisted_cursor, Some(100)); + } + // The invalidation reached the host before anything else of this + // registration did: the host would otherwise restore the old + // coverage (and A's account rows) beside this wallet's advanced + // cursor after a crash. + let invalidated = first_store_is_an_empty_record(&host.stores.lock().unwrap()); + + // The successor scans 101..1500 before the account exists, and the + // host accepts that advance (which also pays the owed 100). + accept_successor_advance(&manager, wallet_id, 1_500).await; + host.stores.lock().unwrap().clear(); + establish_on_recreated(&manager, &host, &recreated, owner, contact, 200).await; + assert_eq!( + recreated + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + Some(200), + "101..1500 was scanned without the account: the old coverage must not suppress this" + ); + { + let stores = host.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the rewind is recorded"); + let core = round.core.as_ref().expect("the rewind rides the round"); + assert_eq!(core.synced_height, Some(200)); + assert!(core.synced_height_is_rewind); + } + + // The crash case: the host restores A's account rows with the cursor + // the successor persisted (1500) and whatever record it holds. With + // the invalidated record the reload rewinds for A; with the old + // record it would have trusted coverage over a range scanned without + // A — which is what storing the invalidation first prevents. + assert_only_the_replaced_record_rearms_after_a_crash( + &manager, + wallet_id, + owner, + 1_500, + invalidated, + host_record, + ) + .await; + } + + /// Restore `wallet_id` the way a crash would — its accounts, `owner`'s + /// established contact and the cursor at `persisted_cursor` — once with + /// the record the publication stored over the host's (`replaced`) and + /// once with the coverage it replaced (`stale`). Only the stored record + /// may rewind for the contact; the stale one would have skipped the + /// range scanned without its account. + async fn assert_only_the_replaced_record_rearms_after_a_crash( + manager: &Arc>, + wallet_id: WalletId, + owner: Identifier, + persisted_cursor: u32, + replaced: crate::changeset::DashPayBackfillRecord, + stale: crate::changeset::DashPayBackfillRecord, + ) { + let (wallet, managed, identity) = { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let mut managed = info.core_wallet.clone(); + managed.metadata.synced_height = persisted_cursor; + let mut identity = info + .identity_manager + .managed_identity(&owner) + .expect("owner") + .clone(); + identity.dashpay_rescan_triggered_mut().clear(); + ( + wm.get_wallet(&wallet_id).expect("wallet").clone(), + managed, + identity, + ) + }; + for (record, expected, why) in [ + (replaced, Some(200), "the stored record re-arms the account"), + ( + stale, + None, + "the replaced coverage would have skipped the unwatched range", + ), + ] { + let mut identities = std::collections::BTreeMap::new(); + identities.insert(0, identity.clone()); + let (restarted, _, _) = reload(ReloadPersister { + wallet: wallet.clone(), + managed: managed.clone(), + identities, + backfill: record, + stores: Mutex::default(), + }) + .await; + assert_eq!( + restarted + .get_wallet(&wallet_id) + .await + .expect("wallet") + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile after reload"), + expected, + "{why}" + ); + } + } + + /// The successor scans to `height` and the host accepts that advance in + /// the successor's own epoch, which also pays any cursor it owed. + async fn accept_successor_advance( + manager: &Arc>, + wallet_id: WalletId, + height: u32, + ) { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let mut durable = manager.durable_cursors.lock().await; + let mut wm = manager.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .core_wallet + .update_synced_height(height); + durable.insert( + wallet_id, + crate::changeset::DurableCursor { + height, + advance_epoch: Some(0), + }, + ); + } + + /// The first thing `stores` received, which must be a record-only round + /// replacing the host's backfill record with an empty one. + fn first_store_is_an_empty_record( + stores: &[(WalletId, PlatformWalletChangeSet)], + ) -> crate::changeset::DashPayBackfillRecord { + let (_, first) = stores.first().expect("the publication stored something"); + assert!(first.core.is_none(), "a record-only round"); + let record = first + .dashpay_backfill + .clone() + .expect("the host's record is replaced before the wallet is published"); + assert!(record.is_empty()); + assert!(!record.has_extent()); + record + } + + /// The snapshot a registration reads is older than its lock. A same-id + /// registration can be published after it, establish a receival account, + /// store that account's coverage, advance and be removed, all before this + /// one publishes — so a snapshot with nothing to prune is no proof the + /// host holds nothing to re-arm. The entry that predecessor left makes + /// the publication replace the host's record anyway; otherwise this + /// wallet scans past the account unwatched and a crash restores the + /// account beside coverage that claims the range was watched + /// (dashpay/platform#4302 review). + #[tokio::test] + async fn a_recreated_wallet_replaces_coverage_a_predecessor_stored_after_the_snapshot() { + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + // What the predecessor stored after this registration's snapshot. + let mut predecessor_record = crate::changeset::DashPayBackfillRecord::default(); + predecessor_record.record_pass(900, Some(200), [(owner, contact, 0, 200)]); + + let (manager, host, recreated, wallet_id) = recreate_on_host_at( + Default::default(), + 1_000, + Some(crate::changeset::DurableCursor { + height: 1_000, + advance_epoch: Some(2), + }), + ) + .await; + let replaced = first_store_is_an_empty_record(&host.stores.lock().unwrap()); + + accept_successor_advance(&manager, wallet_id, 1_500).await; + establish_on_recreated(&manager, &host, &recreated, owner, contact, 200).await; + assert_only_the_replaced_record_rearms_after_a_crash( + &manager, + wallet_id, + owner, + 1_500, + replaced, + predecessor_record, + ) + .await; + } + + /// Load trusts restored coverage only for the receival accounts it + /// actually restored. A host that kept the record but not the account + /// (its account writer missing or failing) would otherwise hand back + /// coverage for account A beside cursor 1000 with no A to watch; the scan + /// can then advance to 1500 before A is registered again, and the old + /// coverage would accept A over a range scanned without it. The load drops + /// that coverage, from the host too, before publishing + /// (dashpay/platform#4302 review). + #[tokio::test] + async fn loading_drops_coverage_for_a_receival_account_the_wallet_did_not_restore() { + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let mut host_record = crate::changeset::DashPayBackfillRecord::default(); + host_record.record_pass(1_000, Some(200), [(owner, contact, 0, 200)]); + + // The host restores the wallet without A's receival account. + let (donor, _, wallet_id) = make_wallet().await; + let (wallet, mut managed) = { + let wm = donor.wallet_manager.read().await; + ( + wm.get_wallet(&wallet_id).expect("wallet").clone(), + wm.get_wallet_info(&wallet_id) + .expect("info") + .core_wallet + .clone(), + ) + }; + drop(donor); + managed.metadata.synced_height = 1_000; + let host = Arc::new(ReloadPersister { + wallet, + managed, + identities: std::collections::BTreeMap::new(), + backfill: host_record, + stores: Mutex::default(), + }); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&host), handler)); + manager + .load_from_persistor() + .await + .expect("the snapshot must load"); + + first_store_is_an_empty_record(&host.stores.lock().unwrap()); + { + let wm = manager.wallet_manager.read().await; + assert!(wm + .get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill + .is_empty()); + } + + // The scan advances without A; then A is registered again. + accept_successor_advance(&manager, wallet_id, 1_500).await; + let loaded = manager.get_wallet(&wallet_id).await.expect("wallet"); + establish_on_recreated(&manager, &host, &loaded, owner, contact, 200).await; + assert_eq!( + loaded + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + Some(200), + "A is rewound for: 1001..1500 was scanned without it" + ); + } + + /// The load path's start state is just as old. A predecessor can register + /// a receival account the snapshot does not hold, store its coverage + /// beside a rewind to 100 and be removed before the load publishes. The + /// loaded cursor (1000) is then above the host's, so no reset is owed, + /// and the snapshot holds no coverage to prune. The entry the predecessor + /// left makes the load replace the host's record with the one it loaded + /// before the wallet is published (dashpay/platform#4302 review). + #[tokio::test] + async fn loading_a_wallet_replaces_coverage_a_predecessor_stored_after_the_snapshot() { + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let mut predecessor_record = crate::changeset::DashPayBackfillRecord::default(); + predecessor_record.record_pass(900, Some(200), [(owner, contact, 0, 200)]); + + let (donor, _, wallet_id) = make_wallet().await; + let (wallet, mut managed) = { + let wm = donor.wallet_manager.read().await; + ( + wm.get_wallet(&wallet_id).expect("wallet").clone(), + wm.get_wallet_info(&wallet_id) + .expect("info") + .core_wallet + .clone(), + ) + }; + drop(donor); + managed.metadata.synced_height = 1_000; + let host = Arc::new(ReloadPersister { + wallet, + managed, + identities: std::collections::BTreeMap::new(), + backfill: Default::default(), + stores: Mutex::default(), + }); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&host), handler)); + manager.durable_cursors.lock().await.insert( + wallet_id, + crate::changeset::DurableCursor { + height: 100, + advance_epoch: Some(2), + }, + ); + manager + .load_from_persistor() + .await + .expect("the snapshot must load"); + let replaced = first_store_is_an_empty_record(&host.stores.lock().unwrap()); + { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!(info.core_wallet.metadata.synced_height, 1_000); + assert_eq!( + info.dashpay_backfill.unpersisted_cursor, None, + "the loaded cursor is above the host's, so no reset is owed" + ); + } + + accept_successor_advance(&manager, wallet_id, 1_500).await; + let loaded = manager.get_wallet(&wallet_id).await.expect("wallet"); + establish_on_recreated(&manager, &host, &loaded, owner, contact, 200).await; + assert_only_the_replaced_record_rearms_after_a_crash( + &manager, + wallet_id, + owner, + 1_500, + replaced, + predecessor_record, + ) + .await; + } + + /// A host whose first `load` returns a stale snapshot and every later + /// one the current state: what a loader sees when a same-id predecessor + /// changed the host after the loader read its start state. + struct SequencedHost { + stale: ReloadPersister, + current: ReloadPersister, + loads: std::sync::atomic::AtomicUsize, + /// When set, the second `load` (the loader's re-read) reports that + /// it has started and waits to be released. + reread_gate: Mutex, std::sync::mpsc::Receiver<()>)>>, + } + + impl PlatformWalletPersistence for SequencedHost { + fn store_commits_inline(&self) -> bool { + self.current.store_commits_inline() + } + + fn stores_dashpay_backfill(&self) -> bool { + self.current.stores_dashpay_backfill() + } + + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + self.current.persistence_capabilities() + } + + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.current.store(wallet_id, changeset) + } + + fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + self.current.flush(wallet_id) + } + + fn load(&self) -> Result { + if self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + self.stale.load() + } else { + let gate = self.reread_gate.lock().unwrap().take(); + if let Some((entered, release)) = gate { + let _ = entered.send(()); + let _ = release.recv(); + } + self.current.load() + } + } + } + + /// Coverage is keyed by `(owner, contact, index)`, not by xpub. A same-id + /// predecessor can replace a receival account's xpub under an unchanged + /// key and be removed before a loader that read the old snapshot + /// publishes it. Publishing that snapshot would restore the old xpub + /// with coverage scanned for it, and its scan would then vouch for the + /// key while the host holds the new xpub. With a predecessor's entry + /// present, the loader re-reads the host under the cursor lock and + /// publishes the current account and record instead + /// (dashpay/platform#4302 review). + #[tokio::test] + async fn loading_after_a_predecessor_replaced_an_xpub_publishes_the_current_account() { + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let (donor, donor_persister, wallet_id) = make_wallet().await; + establish_receival_contact( + &donor, + &donor_persister, + wallet_id, + owner, + contact, + 200, + 200, + ) + .await; + // The stale snapshot: xpub A, covered from 200. + let mut stale_record = crate::changeset::DashPayBackfillRecord::default(); + stale_record.record_pass(1_000, Some(200), [(owner, contact, 0, 200)]); + let stale = reload_snapshot(&donor, wallet_id, owner, 1_000, Some(stale_record)).await; + // The host now: the same key under xpub B, and no coverage yet. + let mut current = reload_snapshot( + &donor, + wallet_id, + owner, + 1_000, + Some(crate::changeset::DashPayBackfillRecord::default()), + ) + .await; + drop(donor); + let xpub_a = stale + .wallet + .accounts + .dashpay_receival_accounts + .values() + .next() + .expect("the receival account") + .account_xpub; + let xpub_b = current + .wallet + .accounts + .standard_bip44_accounts + .get(&0) + .expect("bip44 account") + .account_xpub; + assert_ne!(xpub_a, xpub_b); + current + .wallet + .accounts + .dashpay_receival_accounts + .values_mut() + .next() + .expect("the receival account") + .account_xpub = xpub_b; + + let host = Arc::new(SequencedHost { + stale, + current, + loads: std::sync::atomic::AtomicUsize::new(0), + reread_gate: Mutex::new(None), + }); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&host), handler)); + // The entry the removed predecessor left. + manager + .durable_cursors + .lock() + .await + .insert(wallet_id, crate::changeset::DurableCursor::at(1_000)); + manager + .load_from_persistor() + .await + .expect("the current state must load"); + + let wm = manager.wallet_manager.read().await; + let published = wm + .get_wallet(&wallet_id) + .expect("wallet") + .accounts + .dashpay_receival_accounts + .values() + .next() + .expect("the receival account") + .account_xpub; + assert_eq!(published, xpub_b, "the host's current xpub is published"); + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill + .covered_from(&owner, &contact, 0), + None, + "coverage scanned for the old xpub must not be published" + ); + assert_eq!( + host.loads.load(std::sync::atomic::Ordering::SeqCst), + 2, + "the stale snapshot is re-read once, under the lock" + ); + } + + /// The re-read is only current while nothing can change the host. A + /// same-id wallet can register, rotate an account's xpub and be removed + /// without moving the durable cursor entry, so no token can prove the + /// read is still current once the lock is released. The loader keeps the + /// cursor lock from the re-read through the wallet's publication instead. + /// A same-id registration needs that lock, so the first moment one could + /// run is after the re-read state is published (dashpay/platform#4302 + /// review). + #[tokio::test] + async fn the_loader_holds_the_cursor_lock_from_its_re_read_to_publication() { + use std::future::Future; + + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let (donor, donor_persister, wallet_id) = make_wallet().await; + establish_receival_contact( + &donor, + &donor_persister, + wallet_id, + owner, + contact, + 200, + 200, + ) + .await; + let stale = reload_snapshot(&donor, wallet_id, owner, 1_000, None).await; + let current = reload_snapshot(&donor, wallet_id, owner, 1_000, None).await; + drop(donor); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let host = Arc::new(SequencedHost { + stale, + current, + loads: std::sync::atomic::AtomicUsize::new(0), + reread_gate: Mutex::new(Some((entered_tx, release_rx))), + }); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&host), handler)); + // The entry a removed same-id predecessor left. + manager + .durable_cursors + .lock() + .await + .insert(wallet_id, crate::changeset::DurableCursor::at(1_000)); + + let loader = tokio::spawn({ + let manager = Arc::clone(&manager); + async move { manager.load_from_persistor().await } + }); + // Bounded inside the closure: a running blocking task cannot be + // cancelled, so a loader that skipped the re-read would otherwise + // hang the test instead of failing it. + tokio::task::spawn_blocking(move || { + entered_rx.recv_timeout(std::time::Duration::from_secs(10)) + }) + .await + .expect("join") + .expect("the loader re-reads the host within the deadline"); + + // A same-id registration queues for the cursor lock while the + // re-read runs. + let mut registration = Box::pin(manager.durable_cursors.lock()); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert!( + registration.as_mut().poll(&mut cx).is_pending(), + "the loader holds the cursor lock during its re-read" + ); + release_tx.send(()).expect("release the re-read"); + + let guard = registration.await; + assert!( + manager + .wallet_manager + .read() + .await + .get_wallet(&wallet_id) + .is_some(), + "nothing may take the cursor lock between the re-read and the publication" + ); + drop(guard); + loader + .await + .expect("join") + .expect("the re-read state must load"); + assert_eq!(host.loads.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + /// A backend whose `store` only buffers commits at a later `flush` that + /// can still fail and drop the round, so a "stored" record is not proof + /// of anything on disk. Such a backend gets no record: the pass rewinds + /// and marks in memory only — the pre-record guard — and no accepted + /// store moves the durable cursor. A relaunch then finds no record and + /// rewinds again: slow, never lossy (dashpay/platform#4302 review). + #[tokio::test] + async fn a_buffering_backend_keeps_the_backfill_record_in_memory_only() { + let (manager, persister, wallet_id) = make_wallet().await; + *persister.buffered.lock().unwrap() = true; + *persister.fail_flushes.lock().unwrap() = true; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + let durable_before = manager + .durable_cursors + .lock() + .await + .get(&wallet_id) + .copied(); + persister.stores.lock().unwrap().clear(); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let dashpay = wallet.identity().dashpay(); + assert_eq!( + dashpay.reconcile_dashpay_rescan().await.expect("reconcile"), + Some(100), + "the rewind still runs, in memory" + ); + assert_eq!( + dashpay.reconcile_dashpay_rescan().await.expect("reconcile"), + None, + "the in-memory guard stops a second rewind this session" + ); + assert!( + last_stored_record(&persister.stores.lock().unwrap()).is_none(), + "no record is handed to a backend whose store is not the commit" + ); + assert_eq!( + manager + .durable_cursors + .lock() + .await + .get(&wallet_id) + .copied(), + durable_before, + "nothing it accepted is taken as durable" + ); + { + let wm = manager.wallet_manager.read().await; + let record = &wm + .get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill; + assert_eq!(record.covered_from(&owner, &contact, 0), Some(100)); + assert_eq!( + record.unpersisted_cursor, None, + "no debt for a record never stored" + ); + } + + // A relaunch finds no record on the host and rewinds again. + let snapshot = + reload_snapshot(&manager, wallet_id, owner, 1_000, Some(Default::default())).await; + let (restarted, _, _) = reload(snapshot).await; + assert_eq!( + restarted + .get_wallet(&wallet_id) + .await + .expect("wallet") + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile after reload"), + Some(100) + ); + } + + /// Inline is not enough: a backend that does not attest atomic + /// changesets may keep the coverage of a round and then reject the cursor + /// it vouches for, and a crash before the retry would restore coverage + /// beside the old cursor. Such a backend gets session-only coverage, like + /// a buffering one, so there is never a partial round to leave behind + /// (dashpay/platform#4302 review). + #[tokio::test] + async fn a_non_atomic_backend_keeps_the_backfill_record_in_memory_only() { + let (manager, persister, wallet_id) = make_wallet().await; + *persister.non_atomic.lock().unwrap() = true; + *persister.partial_rounds.lock().unwrap() = true; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + persister.stores.lock().unwrap().clear(); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let dashpay = wallet.identity().dashpay(); + assert_eq!( + dashpay.reconcile_dashpay_rescan().await.expect("reconcile"), + Some(100) + ); + assert_eq!( + dashpay.reconcile_dashpay_rescan().await.expect("reconcile"), + None, + "the in-memory guard still holds for the session" + ); + assert!( + last_stored_record(&persister.stores.lock().unwrap()).is_none(), + "no record reaches a backend that could keep it without its cursor" + ); + let snapshot = + reload_snapshot(&manager, wallet_id, owner, 1_000, Some(Default::default())).await; + let (restarted, _, _) = reload(snapshot).await; + assert_eq!( + restarted + .get_wallet(&wallet_id) + .await + .expect("wallet") + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile after reload"), + Some(100), + "a relaunch rewinds again: slow, never lossy" + ); + } + + /// [`ReloadPersister`] behind an inline backend that does not attest + /// atomic changesets. + struct NonAtomicHost(Arc); + + impl PlatformWalletPersistence for NonAtomicHost { + fn store_commits_inline(&self) -> bool { + true + } + + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.0.store(wallet_id, changeset) + } + + fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + self.0.flush(wallet_id) + } + + fn load(&self) -> Result { + self.0.load() + } + } + + /// A non-atomic backend that still holds a record from an earlier run + /// must not have it trusted, nor left behind: the load carries no record + /// and replaces the host's with an empty one before publishing. + #[tokio::test] + async fn loading_on_a_non_atomic_backend_invalidates_the_hosts_record() { + let (donor, donor_persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact( + &donor, + &donor_persister, + wallet_id, + owner, + contact, + 200, + 200, + ) + .await; + let mut record = crate::changeset::DashPayBackfillRecord::default(); + record.record_pass(800, None, [(owner, contact, 0, 200)]); + let host = Arc::new(reload_snapshot(&donor, wallet_id, owner, 800, Some(record)).await); + drop(donor); + + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::new(NonAtomicHost(Arc::clone(&host))), + handler, + )); + manager + .load_from_persistor() + .await + .expect("the snapshot must load"); + first_store_is_an_empty_record(&host.stores.lock().unwrap()); + let wm = manager.wallet_manager.read().await; + assert!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill + .is_empty(), + "the restored record is not trusted in memory either" + ); + } + + /// Two outbound-account registrations of one contact must not interleave: + /// one that decrypted a payload before a rotation, and lost the race to + /// the build from the rotated request, must not overwrite the newer + /// account's durable xpub and pool and leave the newer marker beside them. + /// The absence check, the live-request check and the durable write run + /// under one guard, and the account and its marker are written in one + /// round (dashpay/platform#4302 review). + #[tokio::test] + async fn a_superseded_outbound_registration_writes_nothing_and_the_marker_stays_bound() { + use crate::wallet::identity::network::contacts::ExternalAccountRegistration; + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + use key_wallet::account::account_collection::DashpayAccountKey; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0x01; 32]); + let shared_key = [0x55u8; 32]; + // A: the payload an older build decrypted. B: the rotated request. + let payload_a = encrypted_contact_xpub(&owner, &Identifier::from([0x02; 32]), &shared_key); + let payload_b = encrypted_contact_xpub(&owner, &contact, &shared_key); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + { + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 8, payload_b.clone(), 100, 0); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + } + let register = |payload: Vec| { + let iw = iw.clone(); + async move { + iw.dashpay() + .register_external_contact_account( + &owner, + &bare_identity(contact.to_buffer()), + &payload, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register") + } + }; + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + let registrations = |stores: &[(WalletId, PlatformWalletChangeSet)]| { + stores + .iter() + .filter(|(_, cs)| !cs.account_registrations.is_empty()) + .count() + }; + + // A's payload against the live B request, account absent: refused, + // nothing written, nothing inserted. + persister.stores.lock().unwrap().clear(); + assert_eq!( + register(payload_a.clone()).await, + ExternalAccountRegistration::Superseded + ); + assert_eq!(registrations(&persister.stores.lock().unwrap()), 0); + { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert!(!info + .core_wallet + .accounts + .dashpay_external_accounts + .contains_key(&key)); + } + + // B builds: the account and the marker naming B ride ONE round. + assert_eq!( + register(payload_b.clone()).await, + ExternalAccountRegistration::Built + ); + { + let stores = persister.stores.lock().unwrap(); + let round = stores + .iter() + .map(|(_, cs)| cs) + .find(|cs| !cs.account_registrations.is_empty()) + .expect("the account was stored"); + let bound = round + .contacts + .as_ref() + .and_then(|contacts| contacts.established.values().next()) + .expect("the marker rides the account's round"); + assert_eq!(bound.external_account_reference, Some(8)); + } + let b_xpub = { + let wm = manager.wallet_manager.read().await; + wm.get_wallet(&wallet_id) + .expect("wallet") + .accounts + .dashpay_external_accounts[&key] + .account_xpub + }; + + // A, having passed its early check before B was built, resumes: it + // finds B's account under the guard and writes nothing. + persister.stores.lock().unwrap().clear(); + assert_eq!( + register(payload_a).await, + ExternalAccountRegistration::AlreadyExisted + ); + assert_eq!(registrations(&persister.stores.lock().unwrap()), 0); + + // What a restart restores: B's account, with B's marker, trusted. + let (restarted, _, _) = + reload(reload_snapshot(&manager, wallet_id, owner, 1_000, None).await).await; + let wm = restarted.wallet_manager.read().await; + assert_eq!( + wm.get_wallet(&wallet_id) + .expect("wallet") + .accounts + .dashpay_external_accounts[&key] + .account_xpub, + b_xpub + ); + let established = wm + .get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("owner") + .dashpay() + .established_contacts()[&contact] + .clone(); + assert_eq!(established.external_account_reference, Some(8)); + } + + /// Outbound registrations of one account are serialized: the durable + /// write runs under the wallet-manager write guard, so nothing else can + /// take the manager while it is held, and a competing registration that + /// passed its early read-guard check before the account existed finds it + /// under the guard and writes nothing (dashpay/platform#4302 review). + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn competing_outbound_registrations_are_serialized_under_the_write_guard() { + use crate::wallet::identity::network::contacts::ExternalAccountRegistration; + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + use std::sync::atomic::Ordering; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + // A contact no other test registers, so the rendezvous holds only these. + let contact = Identifier::from([0x7E; 32]); + let shared_key = [0x55u8; 32]; + let payload = encrypted_contact_xpub(&owner, &contact, &shared_key); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + { + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 7, payload.clone(), 100, 0); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + } + let (release, gate) = std::sync::mpsc::channel::<()>(); + *persister.account_gate.lock().unwrap() = Some(gate); + persister.stores.lock().unwrap().clear(); + + // Both registrations pass the early read-guard check while the + // account is absent, and meet at the rendezvous before either asks + // for the write guard — so the loser can only learn that the account + // exists from the re-check under that guard. + crate::wallet::identity::network::contacts::registration_rendezvous::install(contact, 2); + let register = || { + let iw = iw.clone(); + let payload = payload.clone(); + tokio::spawn(async move { + iw.dashpay() + .register_external_contact_account( + &owner, + &bare_identity(contact.to_buffer()), + &payload, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register") + }) + }; + let first = register(); + let second = register(); + + // One of them holds the write guard inside its durable write. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !persister.gate_entered.load(Ordering::SeqCst) { + assert!( + std::time::Instant::now() < deadline, + "no registration reached its store" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!( + manager.wallet_manager.try_write().is_err() + && manager.wallet_manager.try_read().is_err(), + "the manager stays locked while the registration's store is in progress" + ); + release.send(()).expect("release the store"); + + let mut outcomes = vec![first.await.expect("first"), second.await.expect("second")]; + crate::wallet::identity::network::contacts::registration_rendezvous::remove(&contact); + outcomes.sort_by_key(|outcome| *outcome == ExternalAccountRegistration::Built); + assert_eq!( + outcomes, + vec![ + ExternalAccountRegistration::AlreadyExisted, + ExternalAccountRegistration::Built + ] + ); + let account_rounds = persister + .stores + .lock() + .unwrap() + .iter() + .filter(|(_, cs)| !cs.account_registrations.is_empty()) + .count(); + assert_eq!(account_rounds, 1, "the losing registration wrote nothing"); + } + + /// The marker rides the account's round only where that round is + /// all-or-nothing. A host without atomic rounds can reject a rotated + /// account and its pool yet keep the round's contact rows; with the + /// marker in that round, the live request's marker would sit on disk + /// beside the previous account the host still holds, and a restart would + /// trust it. On such a host the round carries no marker, and the old one + /// stays stale so the sweep rebuilds (dashpay/platform#4302 review). + #[tokio::test] + async fn a_rejected_rotation_on_a_non_atomic_host_leaves_no_current_marker_on_disk() { + use crate::wallet::identity::network::contact_requests::external_account_needs_rebuild; + use crate::wallet::identity::network::contacts::ExternalAccountRegistration; + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + use key_wallet::account::account_collection::DashpayAccountKey; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0x01; 32]); + let shared_key = [0x55u8; 32]; + let payload_old = encrypted_contact_xpub(&owner, &contact, &shared_key); + let payload_new = + encrypted_contact_xpub(&owner, &Identifier::from([0x02; 32]), &shared_key); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let establish = |reference: u32, payload: Vec| { + let iw = iw.clone(); + let persister = Arc::clone(&persister); + async move { + let p = WalletPersister::new(wallet_id, persister as _); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + if info.identity_manager.managed_identity(&owner).is_none() { + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + } + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + let marker = managed + .dashpay() + .established_contacts() + .get(&contact) + .and_then(|c| c.external_account_reference); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0); + let incoming = + ContactRequest::new(contact, owner, 0, 0, reference, payload, 100, 0); + let mut established = EstablishedContact::new(contact, outgoing, incoming); + established.external_account_reference = marker; + managed.apply_established_contact(established); + } + }; + let register = |payload: Vec| { + let iw = iw.clone(); + async move { + iw.dashpay() + .register_external_contact_account( + &owner, + &bare_identity(contact.to_buffer()), + &payload, + zeroize::Zeroizing::new(shared_key), + ) + .await + } + }; + + // Built on an atomic host from request 7: account and marker together. + establish(7, payload_old.clone()).await; + assert_eq!( + register(payload_old).await.expect("first build"), + ExternalAccountRegistration::Built + ); + + // The contact rotates to request 8 and the sweep tears the account + // down in memory; the host still holds the old one. The host now + // has no atomic rounds and rejects the rebuilt account and pool. + establish(8, payload_new.clone()).await; + { + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + let mut wm = manager.wallet_manager.write().await; + let (wallet, info) = wm.get_wallet_mut_and_info_mut(&wallet_id).expect("wallet"); + wallet.accounts.dashpay_external_accounts.remove(&key); + info.core_wallet + .accounts + .dashpay_external_accounts + .remove(&key); + } + *persister.non_atomic.lock().unwrap() = true; + *persister.reject_account_rounds.lock().unwrap() = true; + persister.stores.lock().unwrap().clear(); + assert!( + register(payload_new).await.is_err(), + "the host rejected the account" + ); + + let reached_disk = persister + .stores + .lock() + .unwrap() + .iter() + .filter_map(|(_, cs)| cs.contacts.as_ref()) + .flat_map(|contacts| contacts.established.values()) + .any(|established| established.external_account_reference == Some(8)); + assert!( + !reached_disk, + "the live request's marker must not reach disk beside the previous account" + ); + let wm = manager.wallet_manager.read().await; + let established = wm + .get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("owner") + .dashpay() + .established_contacts()[&contact] + .clone(); + assert_eq!(established.external_account_reference, Some(7)); + assert!( + external_account_needs_rebuild(&established, true), + "the stale marker keeps the repair armed" + ); + } + + /// Where markers are kept on disk, registration is their only writer. A + /// completion that arrives late — after the account was torn down for a + /// rotation whose request reused the same ciphertext under a new + /// reference — must not stamp the new reference beside the obsolete + /// durable account; it only heals the broken flag while the bound marker + /// is still current (dashpay/platform#4302 review). + #[tokio::test] + async fn a_late_completion_never_stamps_a_durable_marker() { + use crate::wallet::identity::network::contact_requests::external_account_needs_rebuild; + use crate::wallet::identity::network::contacts::ExternalAccountRegistration; + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + use key_wallet::account::account_collection::DashpayAccountKey; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0x01; 32]); + let shared_key = [0x55u8; 32]; + let payload = encrypted_contact_xpub(&owner, &contact, &shared_key); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let set_request = |reference: u32| { + let iw = iw.clone(); + let persister = Arc::clone(&persister); + let payload = payload.clone(); + async move { + let p = WalletPersister::new(wallet_id, persister as _); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + if info.identity_manager.managed_identity(&owner).is_none() { + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + } + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + let marker = managed + .dashpay() + .established_contacts() + .get(&contact) + .and_then(|c| c.external_account_reference); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0); + let incoming = + ContactRequest::new(contact, owner, 0, 0, reference, payload, 100, 0); + let mut established = EstablishedContact::new(contact, outgoing, incoming); + established.external_account_reference = marker; + managed.apply_established_contact(established); + } + }; + let established = || { + let manager = Arc::clone(&manager); + async move { + let wm = manager.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("owner") + .dashpay() + .established_contacts()[&contact] + .clone() + } + }; + + // Built from request 7: registration binds the marker. + set_request(7).await; + assert_eq!( + iw.dashpay() + .register_external_contact_account( + &owner, + &bare_identity(contact.to_buffer()), + &payload, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register"), + ExternalAccountRegistration::Built + ); + assert_eq!(established().await.external_account_reference, Some(7)); + + // A rotation to request 8 reuses the ciphertext; the sweep tears the + // account down in memory while the host still holds the old one. + set_request(8).await; + { + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + let mut wm = manager.wallet_manager.write().await; + let (wallet, info) = wm.get_wallet_mut_and_info_mut(&wallet_id).expect("wallet"); + wallet.accounts.dashpay_external_accounts.remove(&key); + info.core_wallet + .accounts + .dashpay_external_accounts + .remove(&key); + } + persister.stores.lock().unwrap().clear(); + + // The first build's completion arrives now. + iw.dashpay() + .note_external_account_registered(&owner, &contact, &payload) + .await; + let after = established().await; + assert_eq!( + after.external_account_reference, + Some(7), + "the late completion must not stamp request 8" + ); + assert!(external_account_needs_rebuild(&after, true)); + let stamped = persister + .stores + .lock() + .unwrap() + .iter() + .filter_map(|(_, cs)| cs.contacts.as_ref()) + .flat_map(|contacts| contacts.established.values()) + .any(|row| row.external_account_reference == Some(8)); + assert!(!stamped, "nothing naming request 8 reached disk"); + + // Cold restoration keeps the stale marker, so the sweep rebuilds. + let (restarted, _, _) = + reload(reload_snapshot(&manager, wallet_id, owner, 1_000, None).await).await; + let restored = { + let wm = restarted.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("owner") + .dashpay() + .established_contacts()[&contact] + .clone() + }; + assert_eq!(restored.external_account_reference, Some(7)); + assert!(external_account_needs_rebuild(&restored, true)); + + // While the bound marker is current, completion still heals the + // broken flag. + set_request(7).await; + { + let mut wm = manager.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .established_contact_mut(&contact) + .expect("contact") + .payment_channel_broken = true; + } + iw.dashpay() + .note_external_account_registered(&owner, &contact, &payload) + .await; + let healed = established().await; + assert!(!healed.payment_channel_broken); + assert_eq!(healed.external_account_reference, Some(7)); + } + + /// A marker may reach disk only where the account it names is durably + /// written with it. A buffering backend can accept the account and the + /// marker and then drop both at a failed `flush`, while a later contact + /// snapshot re-sends the marker alone; a backend without an account + /// writer acknowledges the account without keeping it. On either, every + /// contact row is written without the marker and a restored one is not + /// trusted, so the sweep rebuilds the account after a restart instead of + /// pairing the live request's marker with the previous account + /// (dashpay/platform#4302 review). + #[tokio::test] + async fn outbound_markers_stay_in_memory_where_the_account_is_not_durably_written() { + use crate::wallet::identity::network::contact_requests::external_account_needs_rebuild; + use crate::wallet::identity::network::contacts::ExternalAccountRegistration; + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + + for buffered in [true, false] { + let (manager, persister, wallet_id) = make_wallet().await; + if buffered { + *persister.buffered.lock().unwrap() = true; + *persister.fail_flushes.lock().unwrap() = true; + } else { + *persister.no_account_writer.lock().unwrap() = true; + } + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0x01; 32]); + let shared_key = [0x55u8; 32]; + let payload = encrypted_contact_xpub(&owner, &contact, &shared_key); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + { + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0); + let incoming = + ContactRequest::new(contact, owner, 0, 0, 8, payload.clone(), 100, 0); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_established_contact(EstablishedContact::new( + contact, outgoing, incoming, + )); + } + persister.stores.lock().unwrap().clear(); + assert_eq!( + iw.dashpay() + .register_external_contact_account( + &owner, + &bare_identity(contact.to_buffer()), + &payload, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register"), + ExternalAccountRegistration::Built + ); + iw.dashpay() + .note_external_account_registered(&owner, &contact, &payload) + .await; + // A flush on the buffering backend fails and drops the round. + if buffered { + assert!(wallet.flush_persist().is_err()); + } + + // Memory keeps the self-heal marker; no row on disk carries it. + let established = { + let wm = manager.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("owner") + .dashpay() + .established_contacts()[&contact] + .clone() + }; + assert_eq!(established.external_account_reference, Some(8)); + let marked_rows = persister + .stores + .lock() + .unwrap() + .iter() + .filter_map(|(_, cs)| cs.contacts.as_ref()) + .flat_map(|contacts| contacts.established.values()) + .filter(|row| row.external_account_reference.is_some()) + .count(); + assert_eq!(marked_rows, 0, "buffered: {buffered}"); + + // A cold reload from such a backend does not trust a marker it + // hands back, even one an earlier configuration wrote. + let snapshot = Arc::new(reload_snapshot(&manager, wallet_id, owner, 1_000, None).await); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let restarted = if buffered { + let host = Arc::new(BufferedHost { + inner: Arc::clone(&snapshot), + fail_flushes: std::sync::atomic::AtomicBool::new(false), + }); + let manager = Arc::new(PlatformWalletManager::new(sdk, host, handler)); + manager.load_from_persistor().await.expect("load"); + let wm = manager.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("owner") + .dashpay() + .established_contacts()[&contact] + .clone() + } else { + let host = Arc::new(NonAtomicHost(Arc::clone(&snapshot))); + let manager = Arc::new(PlatformWalletManager::new(sdk, host, handler)); + manager.load_from_persistor().await.expect("load"); + let wm = manager.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("owner") + .dashpay() + .established_contacts()[&contact] + .clone() + }; + assert_eq!( + restarted.external_account_reference, None, + "buffered: {buffered}" + ); + assert!(external_account_needs_rebuild(&restarted, true)); + } + } + + /// A wallet with one established contact whose outbound account is + /// registered and whose marker (7) is stamped: the state the restored- + /// marker validation runs against. + async fn wallet_with_a_marked_outbound_account() -> ( + Arc>, + WalletId, + Identifier, + Identifier, + key_wallet::account::account_collection::DashpayAccountKey, + ) { + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + use key_wallet::account::account_collection::DashpayAccountKey; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0x01; 32]); + let shared_key = [0x55u8; 32]; + let encrypted = encrypted_contact_xpub(&owner, &contact, &shared_key); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + { + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 7, encrypted.clone(), 100, 0); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + } + iw.dashpay() + .register_external_contact_account( + &owner, + &bare_identity(contact.to_buffer()), + &encrypted, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register outbound"); + iw.dashpay() + .note_external_account_registered(&owner, &contact, &encrypted) + .await; + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + (manager, wallet_id, owner, contact, key) + } + + /// A restored outbound-account marker suppresses the startup rebuild, so + /// it must not vouch for a pool its account's xpub does not derive. A + /// host that replaced the xpub on rotation but kept the old pool's rows + /// would otherwise hand out pre-rotation addresses on the next send. + /// The load clears the marker for such a pool, and the contact sweep + /// rebuilds the account from the current xpub (dashpay/platform#4302 + /// review). + #[tokio::test] + async fn a_restored_marker_is_not_trusted_over_a_pool_its_current_xpub_does_not_derive() { + use crate::wallet::identity::network::contact_requests::external_account_needs_rebuild; + + let (manager, wallet_id, owner, contact, key) = + wallet_with_a_marked_outbound_account().await; + + for (rotated, marker) in [(false, Some(7)), (true, None)] { + let mut snapshot = reload_snapshot(&manager, wallet_id, owner, 1_000, None).await; + if rotated { + // The host replaced the account's xpub and kept the old rows. + snapshot + .wallet + .accounts + .dashpay_external_accounts + .get_mut(&key) + .expect("outbound account") + .account_xpub = test_receiving_xpub(&contact, &owner); + } + let (restarted, _, _) = reload(snapshot).await; + let wm = restarted.wallet_manager.read().await; + let established = wm + .get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("owner") + .dashpay() + .established_contacts()[&contact] + .clone(); + assert_eq!( + established.external_account_reference, marker, + "rotated: {rotated}" + ); + assert_eq!( + external_account_needs_rebuild(&established, true), + rotated, + "only a pool the current xpub does not derive is rebuilt" + ); + } + } + + /// Validating a restored pool derives every restored address, and only a + /// marker can be distrusted by it. So the load derives nothing for a + /// relationship restored without a marker, nor on a backend whose markers + /// are all cleared anyway because it cannot keep them durably; it still + /// validates a marker it would otherwise trust (dashpay/platform#4302 + /// review). + #[tokio::test] + async fn restored_pools_are_validated_only_where_a_marker_could_be_trusted() { + use crate::manager::pool_validations_on_this_thread as validations; + + let (manager, wallet_id, owner, contact, key) = + wallet_with_a_marked_outbound_account().await; + // Rotated, so a validation would distrust the marker if it ran. + let snapshot = |marker: bool| { + let manager = Arc::clone(&manager); + async move { + let mut snapshot = reload_snapshot(&manager, wallet_id, owner, 1_000, None).await; + snapshot + .wallet + .accounts + .dashpay_external_accounts + .get_mut(&key) + .expect("outbound account") + .account_xpub = test_receiving_xpub(&contact, &owner); + if !marker { + snapshot + .identities + .get_mut(&0) + .expect("owner") + .established_contact_mut(&contact) + .expect("contact") + .external_account_reference = None; + } + snapshot + } + }; + // No marker restored: nothing to validate. + let before = validations(); + let _ = reload(snapshot(false).await).await; + assert_eq!( + validations(), + before, + "a markerless relationship costs no derivation" + ); + + // A backend that cannot keep markers: all are cleared, none validated. + let before = validations(); + let host = Arc::new(NonAtomicHost(Arc::new(snapshot(true).await))); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let restarted = Arc::new(PlatformWalletManager::new(sdk, host, handler)); + restarted.load_from_persistor().await.expect("load"); + assert_eq!( + validations(), + before, + "an ineligible backend derives nothing" + ); + { + let wm = restarted.wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("owner") + .dashpay() + .established_contacts()[&contact] + .external_account_reference, + None, + "and its marker is still cleared" + ); + } + + // A marker an eligible backend would trust is still validated. + let before = validations(); + let _ = reload(snapshot(true).await).await; + assert!( + validations() > before, + "a trusted marker's pool is validated" + ); + } + + /// [`ReloadPersister`] behind a backend whose `store` only buffers, with + /// a `flush` that can be made to fail transiently. + struct BufferedHost { + inner: Arc, + fail_flushes: std::sync::atomic::AtomicBool, + } + + impl PlatformWalletPersistence for BufferedHost { + fn stores_dashpay_backfill(&self) -> bool { + true + } + + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS + } + + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.inner.store(wallet_id, changeset) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + if self.fail_flushes.load(std::sync::atomic::Ordering::SeqCst) { + return Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Transient, + "injected flush failure", + )); + } + Ok(()) + } + + fn load(&self) -> Result { + self.inner.load() + } + } + + /// A buffering backend that hands back a record must have it replaced + /// with an empty one, committed with a `flush` before the wallet becomes + /// visible. A failed flush fails the load with the flush's own typed + /// error rather than publishing over a replacement that never reached + /// disk (dashpay/platform#4302 review). + #[tokio::test] + async fn a_buffering_backend_flushes_the_replaced_record_before_publishing() { + let (donor, donor_persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact( + &donor, + &donor_persister, + wallet_id, + owner, + contact, + 200, + 200, + ) + .await; + let mut record = crate::changeset::DashPayBackfillRecord::default(); + record.record_pass(800, None, [(owner, contact, 0, 200)]); + let snapshot = Arc::new(reload_snapshot(&donor, wallet_id, owner, 800, Some(record)).await); + drop(donor); + let host = Arc::new(BufferedHost { + inner: Arc::clone(&snapshot), + fail_flushes: std::sync::atomic::AtomicBool::new(true), + }); + let manager_over = |host: &Arc| { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + Arc::new(PlatformWalletManager::new(sdk, Arc::clone(host), handler)) + }; + + let manager = manager_over(&host); + let error = match manager.load_from_persistor().await { + Ok(_) => panic!("the replacement did not commit, so the wallet is not published"), + Err(error) => error, + }; + assert!( + matches!( + &error, + crate::error::PlatformWalletError::PersisterFlush(source) + if source.kind() == Some(PersistenceErrorKind::Transient) + ), + "the flush failure keeps its own type and kind: {error:?}" + ); + assert!(manager.get_wallet(&wallet_id).await.is_none()); + + host.fail_flushes + .store(false, std::sync::atomic::Ordering::SeqCst); + snapshot.stores.lock().unwrap().clear(); + let manager = manager_over(&host); + manager + .load_from_persistor() + .await + .expect("the replacement commits"); + first_store_is_an_empty_record(&snapshot.stores.lock().unwrap()); + } + + /// The replacement can also put coverage BACK: the loaded record covers + /// account A while a same-id predecessor scanned the host's cursor to + /// 1000 without it. Coverage from the snapshot beside cursor 1000 would + /// claim (800, 1000] was watched for A, and a crash straight after the + /// replacement would restore exactly that. So the replacement carries the + /// loaded cursor (800) as a rewind in the same round, and the durable + /// entry follows it — nothing is left owed in memory only + /// (dashpay/platform#4302 review). + #[tokio::test] + async fn loading_a_covering_record_below_the_hosts_cursor_writes_the_loaded_cursor_with_it() { + let (donor, donor_persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact( + &donor, + &donor_persister, + wallet_id, + owner, + contact, + 200, + 200, + ) + .await; + let mut record = crate::changeset::DashPayBackfillRecord::default(); + record.record_pass(800, None, [(owner, contact, 0, 200)]); + let snapshot = reload_snapshot(&donor, wallet_id, owner, 800, Some(record.clone())).await; + drop(donor); + + let host = Arc::new(snapshot); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&host), handler)); + manager.durable_cursors.lock().await.insert( + wallet_id, + crate::changeset::DurableCursor { + height: 1_000, + advance_epoch: Some(2), + }, + ); + manager + .load_from_persistor() + .await + .expect("the snapshot must load"); + + { + let stores = host.stores.lock().unwrap(); + let (_, first) = stores.first().expect("the replacement was stored"); + assert_eq!(first.dashpay_backfill.as_ref(), Some(&record)); + let core = first + .core + .as_ref() + .expect("the loaded cursor rides the replacement round"); + assert_eq!(core.synced_height, Some(800)); + assert!(core.synced_height_is_rewind); + } + assert_eq!( + manager + .durable_cursors + .lock() + .await + .get(&wallet_id) + .copied(), + Some(crate::changeset::DurableCursor::at(800)), + "the entry follows the committed cursor" + ); + let wm = manager.wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill + .unpersisted_cursor, + None, + "nothing is owed: the cursor went to the host with the coverage" + ); + } + + /// The host snapshot a registration reads comes before it takes the + /// durable-cursor lock, and a same-id predecessor still registered then + /// can have a commit accepted in between. Under the lock the entry that + /// commit left is what the host holds now, so it wins over the snapshot: + /// the seed is 1000, not the snapshot's 100, and the built cursor is owed + /// against it (dashpay/platform#4302 review). + #[tokio::test] + async fn a_recreated_wallet_seeds_from_the_cursor_a_predecessor_left_not_the_snapshot() { + let (manager, _host, _recreated, wallet_id) = recreate_on_host_at( + Default::default(), + 100, + Some(crate::changeset::DurableCursor { + height: 1_000, + advance_epoch: Some(2), + }), + ) + .await; + assert_eq!( + manager + .durable_cursors + .lock() + .await + .get(&wallet_id) + .map(|cursor| cursor.height), + Some(1_000), + "the predecessor's accepted commit, not the stale snapshot" + ); + let wm = manager.wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill + .unpersisted_cursor, + Some(100), + "the built cursor is owed against the host's real cursor" + ); + } + + /// A snapshot with no host row is not proof the host holds nothing: a + /// same-id registration can have created the row, advanced it and been + /// removed between the snapshot and this publication. The entry those + /// writes left wins, and the lower built cursor is owed against it. + #[tokio::test] + async fn a_leftover_cursor_entry_wins_without_a_host_row_and_the_lower_built_cursor_is_owed() { + let (donor, _, wallet_id) = make_wallet().await; + drop(donor); + let persister = Arc::new(RecordingPersister::default()); // `load` returns no rows + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::clone(&persister), + handler, + )); + let entry = crate::changeset::DurableCursor { + height: 1_000, + advance_epoch: Some(2), + }; + manager + .durable_cursors + .lock() + .await + .insert(wallet_id, entry); + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) + .expect("valid mnemonic") + .to_seed(""); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed, + WalletAccountCreationOptions::Default, + Some(101), + ) + .await + .expect("create"); + assert_eq!(wallet.wallet_id(), wallet_id); + assert_eq!( + manager + .durable_cursors + .lock() + .await + .get(&wallet_id) + .copied(), + Some(entry), + "the accepted entry is what the host holds" + ); + let wm = manager.wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill + .unpersisted_cursor, + Some(100), + "the lower built cursor is owed" + ); + } + + /// The load path has the same window: the start state is read before + /// the lock, and a predecessor still registered then can have a commit + /// accepted since. The entry wins and the lower loaded cursor is owed, + /// so a contact forward-covered at the loaded cursor is never recorded + /// beside the host's higher one without a reset. + #[tokio::test] + async fn loading_a_wallet_owes_a_loaded_cursor_below_the_one_a_predecessor_left() { + let (donor, _, wallet_id) = make_wallet().await; + let (wallet, mut managed) = { + let wm = donor.wallet_manager.read().await; + ( + wm.get_wallet(&wallet_id).expect("wallet").clone(), + wm.get_wallet_info(&wallet_id) + .expect("info") + .core_wallet + .clone(), + ) + }; + drop(donor); + managed.metadata.synced_height = 100; + let host = Arc::new(ReloadPersister { + wallet, + managed, + identities: std::collections::BTreeMap::new(), + backfill: Default::default(), + stores: Mutex::default(), + }); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let handler: Arc = Arc::new(NoopEventHandler); + let manager = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&host), handler)); + manager.durable_cursors.lock().await.insert( + wallet_id, + crate::changeset::DurableCursor { + height: 1_000, + advance_epoch: Some(2), + }, + ); + manager + .load_from_persistor() + .await + .expect("the snapshot must load"); + assert_eq!( + manager + .durable_cursors + .lock() + .await + .get(&wallet_id) + .map(|cursor| cursor.height), + Some(1_000) + ); + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!(info.core_wallet.metadata.synced_height, 100); + assert_eq!(info.dashpay_backfill.unpersisted_cursor, Some(100)); + } + + /// Coverage for a receival account that no longer exists stops being + /// true the moment its scripts stop being watched. A pass with no + /// contact to handle must still drop it and store the record, not + /// return early and leave it vouching on disk (dashpay/platform#4302 + /// review). + #[tokio::test] + async fn coverage_for_a_removed_receival_account_is_pruned_and_stored() { + use key_wallet::account::account_collection::DashpayAccountKey; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let dashpay = wallet.identity().dashpay(); + assert_eq!( + dashpay.reconcile_dashpay_rescan().await.expect("rewind"), + Some(100) + ); + + { + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.core_wallet + .accounts + .dashpay_receival_accounts + .remove(&DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }); + } + persister.stores.lock().unwrap().clear(); + assert_eq!( + dashpay.reconcile_dashpay_rescan().await.expect("prune"), + None + ); + { + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the pruned record is stored"); + assert!(round.dashpay_backfill.as_ref().unwrap().is_empty()); + } + let wm = manager.wallet_manager.read().await; + assert!(wm + .get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill + .is_empty()); + } + + /// Registering a receival account bumps the wallet's account generation, + /// which the filter pipeline compares per batch: a batch scanned before + /// the registration can no longer certify coverage for the new account + /// set, so forward coverage recorded for the account is backed by a scan + /// that actually watched it (dashpay/platform#4302 review). + #[tokio::test] + async fn receival_registration_invalidates_in_flight_scans() { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let (manager, _persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let generation = |wm: &key_wallet_manager::WalletManager< + crate::wallet::platform_wallet::PlatformWalletInfo, + >| { + WalletInfoInterface::account_generation(wm.get_wallet_info(&wallet_id).expect("info")) + }; + let before = generation(&*manager.wallet_manager.read().await); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register"); + let wm = manager.wallet_manager.read().await; + assert_ne!(generation(&wm), before); + assert_eq!( + key_wallet_manager::WalletInterface::wallet_account_generation(&*wm, &wallet_id), + generation(&wm), + "the engine sees the forwarded generation, not the trait's constant 0" + ); + } + + /// A backend that panics inside the record round's `store()` unwinds + /// through the reconcile and releases its locks. The pass's coverage + /// was only staged, and the cursor it owed was recorded before the + /// store ran, so the next round — a forward-covered contact with no + /// cursor of its own — carries that cursor beside both contacts' + /// coverage instead of storing coverage over the host's high-water + /// (dashpay/platform#4302 review). + #[tokio::test] + async fn a_panicking_record_store_leaves_no_coverage_and_keeps_the_cursor_it_owed() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let first = Identifier::from([0xBB; 32]); + let later = Identifier::from([0xCC; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, first, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + persister.stores.lock().unwrap().clear(); + *persister.panic_next_store.lock().unwrap() = true; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let panicking = { + let wallet = Arc::clone(&wallet); + tokio::spawn( + async move { wallet.identity().dashpay().reconcile_dashpay_rescan().await }, + ) + }; + assert!( + panicking.await.expect_err("the store panicked").is_panic(), + "the injected panic propagates" + ); + { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert!( + info.dashpay_backfill + .covered_from(&owner, &first, 0) + .is_none(), + "staged coverage never reached memory" + ); + assert_eq!(info.dashpay_backfill.unpersisted_cursor, Some(100)); + assert_eq!( + manager + .durable_cursors + .lock() + .await + .get(&wallet_id) + .map(|cursor| cursor.height), + Some(1_000), + "the host still holds its high-water" + ); + } + + establish_receival_contact(&manager, &persister, wallet_id, owner, later, 200, 200).await; + persister.stores.lock().unwrap().clear(); + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("next pass"); + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the record is stored"); + let core = round + .core + .as_ref() + .expect("the owed cursor rides the round"); + assert_eq!(core.synced_height, Some(100)); + assert!(core.synced_height_is_rewind); + let record = round.dashpay_backfill.as_ref().unwrap(); + assert_eq!(record.covered_from(&owner, &first, 0), Some(100)); + assert_eq!(record.covered_from(&owner, &later, 0), Some(200)); + } + + /// A record round that fails to store must not leave its coverage + /// behind in memory: a later round (a forward-covered contact, no + /// cursor of its own) would then store the whole record — this + /// contact included — beside a durable cursor still at its high-water, + /// and the next launch would skip the rescan (dashpay/platform#4302 + /// review). On failure the record is put back, the pass's marks are + /// dropped, and the cursor the round owed rides the next successful + /// round. + #[tokio::test] + async fn a_failed_record_round_is_retried_with_the_cursor_it_owed() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let first = Identifier::from([0xBB; 32]); + let later = Identifier::from([0xCC; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, first, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + persister.stores.lock().unwrap().clear(); + *persister.fail_stores.lock().unwrap() = true; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("pass whose store fails"), + Some(100), + "the in-memory rewind still runs the rescan" + ); + { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!(info.core_wallet.synced_height(), 100); + assert!( + info.dashpay_backfill + .covered_from(&owner, &first, 0) + .is_none(), + "coverage that never reached disk must not stay in memory" + ); + assert_eq!(info.dashpay_backfill.unpersisted_cursor, Some(100)); + assert!( + !info + .identity_manager + .managed_identity(&owner) + .unwrap() + .dashpay() + .rescan_triggered + .contains(&first), + "the pass's mark is dropped so the next pass re-attempts" + ); + } + + // Another contact turns up, forward-covered at the rewound cursor — + // exactly the round that used to store `first` without a cursor. + *persister.fail_stores.lock().unwrap() = false; + establish_receival_contact(&manager, &persister, wallet_id, owner, later, 200, 200).await; + persister.stores.lock().unwrap().clear(); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("retry pass"), + None, + "nothing is below the rewound cursor, so no new rewind" + ); + { + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the retry stores the record"); + assert_eq!( + round.core.as_ref().and_then(|core| core.synced_height), + Some(100), + "the retry carries the cursor the failed round owed" + ); + let record = round.dashpay_backfill.as_ref().unwrap(); + assert_eq!(record.covered_from(&owner, &first, 0), Some(100)); + assert_eq!(record.covered_from(&owner, &later, 0), Some(200)); + assert_eq!( + (record.floor, record.rewound_from), + (100, 1_000), + "the retry keeps the failed rewind's extent" + ); + assert!( + record.is_pending(100), + "the backfill still reads as climbing" + ); + assert!( + round.core.as_ref().unwrap().synced_height_is_rewind, + "the owed cursor rides as a rewind" + ); + } + let wm = manager.wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .unwrap() + .dashpay_backfill + .unpersisted_cursor, + None + ); + } + + /// A process-local mark is not coverage. A contact covered from 100 + /// whose checkpoint then drops to 50 (an older reciprocal request) must + /// be rewound to 50 — not have the lower checkpoint recorded as covered + /// while the range 50–100 was never scanned for it + /// (dashpay/platform#4302 review). + #[tokio::test] + async fn a_marked_contact_whose_checkpoint_dropped_is_rewound_not_recorded_lower() { + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("first pass"), + Some(100) + ); + // The scan climbs; then the reciprocal request turns out to be older. + set_synced_height(&manager, wallet_id, 400).await; + { + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], 50, 0); + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + managed.apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + assert!( + managed.dashpay().rescan_triggered.contains(&contact), + "the mark survives the request change on this tree" + ); + } + persister.stores.lock().unwrap().clear(); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("pass after the drop"), + Some(50), + "a marked contact below its recorded height re-arms through the candidate path" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 50); + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the deeper rewind stores its record"); + assert_eq!( + round.core.as_ref().and_then(|core| core.synced_height), + Some(50) + ); + let record = round.dashpay_backfill.as_ref().unwrap(); + assert_eq!(record.covered_from(&owner, &contact, 0), Some(50)); + assert_eq!((record.floor, record.rewound_from), (50, 1_000)); + } + + /// While the manager's persistence fault is latched the adapter holds + /// the durable cursor back at the last fully persisted height. A record + /// round written then would advance it past rows that never landed, so + /// the reconcile rewinds in memory but stores nothing — the next launch + /// re-runs the backfill, the pre-record behaviour + /// (dashpay/platform#4302 review). The latch never clears, so the pass's + /// coverage is kept for the session: once the scan climbs back past the + /// contact, later sweeps must not rewind to it again. + #[tokio::test] + async fn a_latched_persistence_fault_keeps_the_record_off_disk() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .sync_fault + .store(true, std::sync::atomic::Ordering::Relaxed); + persister.stores.lock().unwrap().clear(); + + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("pass under a latched fault"), + Some(100), + "the in-memory rewind still runs the rescan" + ); + assert!( + persister.stores.lock().unwrap().is_empty(), + "nothing reaches the persister while the fault is latched" + ); + { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!(info.core_wallet.synced_height(), 100); + assert_eq!( + info.dashpay_backfill.covered_from(&owner, &contact, 0), + Some(100), + "the pass's coverage is kept for the session" + ); + assert_eq!( + info.dashpay_backfill.unpersisted_cursor, + Some(100), + "beside the cursor it still owes the host" + ); + } + + // The rescan climbs back past the contact in memory; the latched + // adapter records no durable advance. Later sweeps must not rewind to + // the contact again. + { + let mut wm = manager.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .core_wallet + .update_synced_height(1_000); + } + for _ in 0..2 { + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("later pass under the latched fault"), + None, + "a contact this session already backfilled must not be rewound to again" + ); + } + assert!(persister.stores.lock().unwrap().is_empty()); + let wm = manager.wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .core_wallet + .synced_height(), + 1_000 + ); + } + + /// A record round the backend refuses outright (a non-transient failure) + /// would be refused again. Retrying it every sweep left the contact + /// uncovered, so each sweep after the scan climbed back past it rewound + /// to it again (dashpay/platform#4302 review). The coverage is kept for + /// the session beside the cursor it owes, and no further record round is + /// attempted. + #[tokio::test] + async fn a_permanently_refused_record_is_kept_for_the_session_without_rewinding_again() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 100, 100).await; + set_synced_height(&manager, wallet_id, 1_000).await; + persister.stores.lock().unwrap().clear(); + *persister.refuse_stores.lock().unwrap() = true; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("pass whose store is refused"), + Some(100), + "the in-memory rewind still runs the rescan" + ); + assert_eq!( + persister + .refused_attempts + .load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + { + let wm = manager.wallet_manager.read().await; + let record = &wm + .get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill; + assert_eq!( + record.covered_from(&owner, &contact, 0), + Some(100), + "the refused round's coverage is kept for the session" + ); + assert_eq!( + record.unpersisted_cursor, + Some(100), + "beside the cursor it owes" + ); + assert!(record.store_refused); + } + + // The rescan climbs back past the contact over several advances, + // with a sweep after each. None may rewind again or retry the store. + for height in [400, 700, 1_000] { + { + let mut wm = manager.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .core_wallet + .update_synced_height(height); + } + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("later pass"), + None, + "no rewind at {height} for a contact this session already backfilled" + ); + } + assert_eq!( + persister + .refused_attempts + .load(std::sync::atomic::Ordering::SeqCst), + 1, + "a refused record round is not retried" + ); + assert!(persister.stores.lock().unwrap().is_empty()); + } + + /// The host lags memory (a healthy backlog: the adapter has not yet + /// committed the advances that took memory to 1000). A rewind to 800 + /// must not write 800 — that would claim the rows between the host's + /// cursor and 800 are on disk when they are still queued. The host's + /// cursor is already below the floor, so the record is stored with no + /// cursor, and nothing records a durable height that was never written + /// (dashpay/platform#4302 review). + #[tokio::test] + async fn a_rewind_above_the_hosts_cursor_writes_no_cursor() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 800, 800).await; + set_synced_height(&manager, wallet_id, 100).await; + // Memory advances to 1000; the host still holds 100. + { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let mut wm = wallet.identity().wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .core_wallet + .update_synced_height(1_000); + } + persister.stores.lock().unwrap().clear(); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + Some(800), + "memory rewinds to the floor" + ); + { + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the record is stored"); + assert!( + round.core.is_none(), + "the host's 100 is already below the floor; 800 must not be written" + ); + assert_eq!( + round + .dashpay_backfill + .as_ref() + .unwrap() + .covered_from(&owner, &contact, 0), + Some(800) + ); + } + assert_eq!( + manager + .durable_cursors + .lock() + .await + .get(&wallet_id) + .map(|cursor| cursor.height), + Some(100), + "the recorded durable cursor is still what the host holds" + ); + } + + /// `spv_rescan_filters_blocking` lowers the cursor in memory only. The + /// next record round must carry that reset, or it stores coverage + /// justified by a rewind the host never saw (dashpay/platform#4302 + /// review): here a contact forward-covered at the reset cursor produces + /// a round with no rewind of its own, which now carries the owed 100. + #[tokio::test] + async fn a_manual_rescan_reset_rides_the_next_record_round() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + set_synced_height(&manager, wallet_id, 1_000).await; + { + let manager = Arc::clone(&manager); + tokio::task::spawn_blocking(move || { + assert!(manager.spv_rescan_filters_blocking(&wallet_id, 100)); + }) + .await + .expect("manual rescan"); + } + assert_eq!(synced_height(&manager, wallet_id).await, 100); + assert_eq!( + rewind_epoch(&manager, wallet_id).await, + 1, + "the reset arms the rewind barrier: advances queued before it are not stored after it" + ); + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 200, 200).await; + persister.stores.lock().unwrap().clear(); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + None, + "the contact is forward-covered at the reset cursor" + ); + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the record is stored"); + let core = round.core.as_ref().expect("the owed reset rides the round"); + assert_eq!(core.synced_height, Some(100)); + assert!(core.synced_height_is_rewind); + assert_eq!( + round + .dashpay_backfill + .as_ref() + .unwrap() + .covered_from(&owner, &contact, 0), + Some(200) + ); + } + + /// Reset the cursor to `from` the way the app's "rescan blockchain" + /// does, then model the rescan climbing back to `to` with the adapter + /// committing an advance from rewind epoch `host_epoch`. + async fn reset_then_climb( + manager: &Arc>, + wallet_id: WalletId, + from: u32, + to: u32, + host_epoch: u64, + ) { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + { + let manager = Arc::clone(manager); + tokio::task::spawn_blocking(move || { + assert!(manager.spv_rescan_filters_blocking(&wallet_id, from)); + }) + .await + .expect("manual rescan"); + } + let mut durable = manager.durable_cursors.lock().await; + let mut wm = manager.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .core_wallet + .update_synced_height(to); + durable.insert( + wallet_id, + crate::changeset::DurableCursor { + height: to, + advance_epoch: Some(host_epoch), + }, + ); + } + + /// dashpay/platform#4302 device check: a manual reset owes the host the + /// reset height, but the adapter persists the rescan's own advances as it + /// climbs, so once the host has accepted one from the reset's epoch the + /// debt is paid. A record round after the climb must not write the old + /// reset height — that dragged the host back to it until the next block + /// advance, and a kill in that window re-scanned from it. + #[tokio::test] + async fn an_owed_cursor_the_host_has_climbed_past_is_dropped_not_written() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + set_synced_height(&manager, wallet_id, 1_000).await; + reset_then_climb(&manager, wallet_id, 100, 1_000, 1).await; + assert_eq!(rewind_epoch(&manager, wallet_id).await, 1); + + establish_receival_contact( + &manager, &persister, wallet_id, owner, contact, 1_200, 1_200, + ) + .await; + persister.stores.lock().unwrap().clear(); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"), + None + ); + { + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the contact is recorded"); + assert!( + round.core.is_none(), + "no stale reset cursor rides the round: {:?}", + round.core.as_ref().and_then(|core| core.synced_height) + ); + } + let wm = manager.wallet_manager.read().await; + let record = &wm + .get_wallet_info(&wallet_id) + .expect("info") + .dashpay_backfill; + assert_eq!(record.unpersisted_cursor, None, "the paid debt is gone"); + assert_eq!(record.covered_from(&owner, &contact, 0), Some(1_200)); + } + + /// The other side: an advance the host accepted from BEFORE the reset + /// says nothing about the range the reset re-opened, so the debt stands + /// and rides the next record round. + #[tokio::test] + async fn an_owed_cursor_is_still_paid_when_the_host_only_advanced_before_the_reset() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + set_synced_height(&manager, wallet_id, 1_000).await; + // The host's last accepted advance is from epoch 0, before the reset + // (epoch 1); memory has not moved since the reset. + reset_then_climb(&manager, wallet_id, 100, 100, 0).await; + manager + .durable_cursors + .lock() + .await + .get_mut(&wallet_id) + .expect("seeded") + .height = 1_000; + + establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 200, 200).await; + persister.stores.lock().unwrap().clear(); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"); + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("the record is stored"); + let core = round.core.as_ref().expect("the owed reset rides the round"); + assert_eq!(core.synced_height, Some(100)); + assert!(core.synced_height_is_rewind); + } + + async fn synced_height_of( + manager: &Arc>, + wallet_id: WalletId, + ) -> u32 { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let wm = manager.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .core_wallet + .synced_height() } /// A `Sent` payment must advance `Pending → Confirmed` once its @@ -7755,6 +12119,8 @@ mod tests { wallet_id: real.wallet_id, asset_locks: Arc::clone(&real.asset_locks), persister: real.persister.clone(), + sync_fault: Arc::clone(&real.sync_fault), + durable_cursors: Arc::clone(&real.durable_cursors), broadcaster: Arc::new(GatedRejectingBroadcaster { entered, release }), sdk_writer: Arc::clone(&real.sdk_writer), dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), @@ -7794,6 +12160,8 @@ mod tests { wallet_id: real.wallet_id, asset_locks: Arc::clone(&real.asset_locks), persister: real.persister.clone(), + sync_fault: Arc::clone(&real.sync_fault), + durable_cursors: Arc::clone(&real.durable_cursors), broadcaster: Arc::new(GatedBroadcaster { entered, release }), sdk_writer: Arc::clone(&real.sdk_writer), dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), @@ -7814,6 +12182,8 @@ mod tests { wallet_id: real.wallet_id, asset_locks: Arc::clone(&real.asset_locks), persister: real.persister.clone(), + sync_fault: Arc::clone(&real.sync_fault), + durable_cursors: Arc::clone(&real.durable_cursors), broadcaster: Arc::new(AcceptingBroadcaster), sdk_writer: Arc::clone(&real.sdk_writer), dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), @@ -7849,6 +12219,8 @@ mod tests { wallet_id: real.wallet_id, asset_locks: Arc::clone(&real.asset_locks), persister: real.persister.clone(), + sync_fault: Arc::clone(&real.sync_fault), + durable_cursors: Arc::clone(&real.durable_cursors), broadcaster: Arc::new(RejectingBroadcaster), sdk_writer: Arc::clone(&real.sdk_writer), dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index fdb698f7cbf..ab22da79cd7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -134,13 +134,19 @@ pub struct DashPayState { /// recurring sweep does not re-lower the height every pass — which would /// reset the in-flight backfill and prevent it from ever completing. /// - /// In-memory only (never persisted): a relaunch clears it, and because - /// `synced_height` is restored at its monotonic high-water, an interrupted - /// backfill is re-triggered on the next launch — self-healing. The cost of - /// that reset is one historical re-match per launch while any contact is - /// funded below the tip; the compact filters are reused from disk (not - /// re-downloaded), so it is cheap. A persisted breadcrumb could make the - /// backfill durable across a crash if that ever becomes necessary. + /// This set is the in-memory half of the guard and is never persisted: a + /// relaunch clears it. Its durable half is the wallet's + /// [`DashPayBackfillRecord`](crate::changeset::DashPayBackfillRecord), + /// which the rescan reconcile writes on the same persistence round as the + /// lowered cursor and consults on the next launch — the cursor it lowers + /// is durable too (the host persists every height the rescan climbs + /// through), so without the record every fresh process restored a cursor + /// inside the climb and rewound again (dashpay/platform#4302). The two + /// compose per contact: a mark here says "handled this process", an entry + /// there says "covered from this height"; the request-state transitions + /// that clear a mark here leave the record alone, and the reconcile + /// re-arms a recorded contact only when its checkpoint has dropped below + /// the height it was recorded at. pub rescan_triggered: BTreeSet, /// DashPay contact-crypto ops the unattended background sweep enqueued for diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index a7c3eafaaff..436dbaaabf6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -156,7 +156,8 @@ impl ManagedIdentity { /// Mutable access to the per-session contact-rescan guard set. /// /// In-memory only — never persisted; see the field docs on - /// [`DashPayState::rescan_triggered`] for the self-healing contract. + /// [`DashPayState::rescan_triggered`] for how it composes with the + /// wallet's durable backfill record. pub fn dashpay_rescan_triggered_mut( &mut self, ) -> &mut std::collections::BTreeSet { diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs index 62c74084b8a..114265436f9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/established_contact.rs @@ -82,9 +82,14 @@ pub struct EstablishedContact { /// lets the sweep detect a stale account and tear it down + rebuild. /// /// Reset to `None` on (re-)establish and on rotation (the account must be - /// rebuilt from the new key material). A cold restart that does not - /// restore this field leaves it `None`, which conservatively forces one - /// rebuild on the next sweep — self-healing either way. + /// rebuilt from the new key material). Persisted on the contact's + /// established rows (`ContactRequestFFI::external_account_reference`, the + /// Kotlin `dashpay_contact_requests.externalAccountReference` column) so + /// a cold restart restores it and a healthy account is NOT rebuilt. A + /// host that does not carry it leaves it `None`, which conservatively + /// forces one rebuild on the next sweep — self-healing, but on every + /// launch; the rebuild used to also rewind the filter-scan cursor, which + /// is the per-launch re-walk of dashpay/platform#4302. #[cfg_attr(feature = "serde", serde(default))] pub external_account_reference: Option, } diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 95b5edb470a..0eb3172ef2f 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -82,10 +82,35 @@ impl WalletPersister { self.contact_payment_gate.lock().await } - pub(crate) fn store(&self, changeset: PlatformWalletChangeSet) -> Result<(), PersistenceError> { + /// Store `changeset` for this wallet. + /// + /// On a backend that cannot keep outbound-account markers durably (see + /// [`persists_outbound_account_markers`](crate::changeset::persists_outbound_account_markers)), + /// every established-contact row is written without its marker: a marker + /// that reached disk without its account — through a buffered round that + /// was later dropped, or a host that skips account writes — would be + /// trusted on restart beside the previous account. The in-memory marker is + /// untouched, so the running process keeps its rotation self-heal. + pub(crate) fn store( + &self, + mut changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if !self.persists_outbound_account_markers() { + if let Some(contacts) = changeset.contacts.as_mut() { + for established in contacts.established.values_mut() { + established.external_account_reference = None; + } + } + } self.inner.store(self.wallet_id, changeset) } + /// Whether the backend keeps outbound-account markers durably (see + /// [`persists_outbound_account_markers`](crate::changeset::persists_outbound_account_markers)). + pub(crate) fn persists_outbound_account_markers(&self) -> bool { + crate::changeset::persists_outbound_account_markers(self.inner.as_ref()) + } + /// Preserve backend failure kinds, granting retry only when the backend /// guarantees a failed store retained and committed nothing. pub(crate) fn classify_store_failure(&self, error: PersistenceError) -> PlatformWalletError { @@ -100,6 +125,12 @@ impl WalletPersister { self.inner.store_commits_inline() } + /// Whether the backend can hold a durable DashPay backfill record (see + /// [`persists_backfill_coverage`](crate::changeset::persists_backfill_coverage)). + pub(crate) fn persists_backfill_coverage(&self) -> bool { + crate::changeset::persists_backfill_coverage(self.inner.as_ref()) + } + /// Feature-specific persistence contracts exposed by the backend. pub(crate) fn persistence_capabilities(&self) -> PersistenceCapabilities { self.inner.persistence_capabilities() diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 7cbe111776d..75a80e98a8f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -296,6 +296,20 @@ pub struct PlatformWalletInfo { /// host-side persister mirror fed by /// [`DpnsNameStateChangeSet`](crate::changeset::DpnsNameStateChangeSet). pub dpns_name_states: BTreeMap, + /// Durable half of the DashPay coreHeight backfill guard: which receival + /// contacts the historical rescan already covers, from which height each, + /// and how far the rescan reached. Restored at load from + /// [`ClientWalletStartState::dashpay_backfill`](crate::changeset::ClientWalletStartState::dashpay_backfill) + /// and written by `reconcile_dashpay_rescan` on the round that lowers the + /// cursor. The in-memory half is `DashPayState::rescan_triggered`; see + /// [`DashPayBackfillRecord`](crate::changeset::DashPayBackfillRecord) + /// for how the two compose. + pub dashpay_backfill: crate::changeset::DashPayBackfillRecord, + /// Orders this wallet's queued `SyncHeightAdvanced` events against + /// in-memory rewinds of its scan cursor; see + /// [`RewindBarrier`](crate::changeset::core_bridge::RewindBarrier). In + /// memory only. + pub rewind_barrier: crate::changeset::core_bridge::RewindBarrier, } /// A platform wallet that combines core UTXO functionality with identity management. @@ -661,6 +675,9 @@ impl PlatformWallet { /// Construct a PlatformWallet from a WalletManager that already contains /// the wallet. The wallet must have been inserted into the WalletManager /// before calling this. + // Every argument is a shared handle the manager owns; a builder would + // only move the same nine names behind a struct. + #[allow(clippy::too_many_arguments)] pub(crate) fn new( sdk: Arc, wallet_id: WalletId, @@ -669,6 +686,8 @@ impl PlatformWallet { lock_notify: Arc, persister: Arc, broadcaster: Arc, + sync_fault: Arc, + durable_cursors: crate::changeset::DurableCursors, ) -> Self { // Build the per-wallet persister handle once and share it with // the sub-wallets that need to queue their own changesets @@ -714,6 +733,8 @@ impl PlatformWallet { wallet_id, asset_locks: Arc::clone(&asset_locks), persister: wallet_persister.clone(), + sync_fault, + durable_cursors, broadcaster: dashpay_broadcaster, // DashPay write helper: forwards to the live SDK, erasing its // generic write signatures behind concrete by-value methods. diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index 223fb2d64be..22f051e606d 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -42,6 +42,8 @@ impl WalletInfoInterface for PlatformWalletInfo { tracked_asset_locks: std::collections::BTreeMap::new(), observed_input_conflicts: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), } } @@ -56,6 +58,8 @@ impl WalletInfoInterface for PlatformWalletInfo { tracked_asset_locks: std::collections::BTreeMap::new(), observed_input_conflicts: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), + dashpay_backfill: Default::default(), + rewind_barrier: Default::default(), } } @@ -148,6 +152,26 @@ impl WalletInfoInterface for PlatformWalletInfo { self.core_wallet.last_processed_height() } + /// Forwarded, plus the receival accounts platform inserts itself: the + /// filter pipeline snapshots this per batch and refuses to certify a + /// batch whose snapshot predates an account add, because the scan never + /// tested that account's scripts (dashpay/rust-dashcore#649). The trait + /// default is a constant `0`, which turned the guard off for every + /// platform wallet — so a batch in flight across a contact-account + /// registration could certify coverage the DashPay backfill record then + /// vouched for (dashpay/platform#4302 review). + /// + /// Also offset past a removed same-id predecessor's final generation + /// (see `RewindBarrier::inherit`): both counters restart with the wallet, + /// so without the offset a batch reconciled against the predecessor's + /// account set could match this wallet's generation and be certified. + fn account_generation(&self) -> u64 { + self.core_wallet + .account_generation() + .wrapping_add(self.rewind_barrier.account_registrations()) + .wrapping_add(self.rewind_barrier.generation_base()) + } + fn synced_height(&self) -> CoreBlockHeight { self.core_wallet.synced_height() } @@ -157,7 +181,14 @@ impl WalletInfoInterface for PlatformWalletInfo { .update_last_processed_height(current_height); } + /// The engine's only caller is `update_wallet_synced_height`, which emits + /// a `SyncHeightAdvanced` straight after this returns, under the same + /// manager write lock — so this is where emissions are counted for the + /// [`RewindBarrier`](crate::changeset::core_bridge::RewindBarrier). + /// Platform code that moves the cursor calls `core_wallet` directly and + /// emits nothing. fn update_synced_height(&mut self, current_height: u32) { + self.rewind_barrier.note_emitted(); self.core_wallet.update_synced_height(current_height); } @@ -284,7 +315,10 @@ impl ManagedAccountOperations for PlatformWalletInfo { wallet: &Wallet, account_type: AccountType, ) -> key_wallet::Result<()> { - self.core_wallet.add_managed_account(wallet, account_type) + let before = self.core_wallet.metadata.synced_height; + let added = self.core_wallet.add_managed_account(wallet, account_type); + self.note_account_add_rewind(before); + added } fn add_managed_account_from_xpub( @@ -292,8 +326,12 @@ impl ManagedAccountOperations for PlatformWalletInfo { account_type: AccountType, account_xpub: ExtendedPubKey, ) -> key_wallet::Result<()> { - self.core_wallet - .add_managed_account_from_xpub(account_type, account_xpub) + let before = self.core_wallet.metadata.synced_height; + let added = self + .core_wallet + .add_managed_account_from_xpub(account_type, account_xpub); + self.note_account_add_rewind(before); + added } #[cfg(feature = "bls")] @@ -302,8 +340,12 @@ impl ManagedAccountOperations for PlatformWalletInfo { wallet: &Wallet, account_type: AccountType, ) -> key_wallet::Result<()> { - self.core_wallet - .add_managed_bls_account(wallet, account_type) + let before = self.core_wallet.metadata.synced_height; + let added = self + .core_wallet + .add_managed_bls_account(wallet, account_type); + self.note_account_add_rewind(before); + added } #[cfg(feature = "bls")] @@ -312,8 +354,12 @@ impl ManagedAccountOperations for PlatformWalletInfo { account_type: AccountType, bls_public_key: [u8; 48], ) -> key_wallet::Result<()> { - self.core_wallet - .add_managed_bls_account_from_public_key(account_type, bls_public_key) + let before = self.core_wallet.metadata.synced_height; + let added = self + .core_wallet + .add_managed_bls_account_from_public_key(account_type, bls_public_key); + self.note_account_add_rewind(before); + added } #[cfg(feature = "eddsa")] @@ -322,8 +368,12 @@ impl ManagedAccountOperations for PlatformWalletInfo { wallet: &Wallet, account_type: AccountType, ) -> key_wallet::Result<()> { - self.core_wallet - .add_managed_eddsa_account(wallet, account_type) + let before = self.core_wallet.metadata.synced_height; + let added = self + .core_wallet + .add_managed_eddsa_account(wallet, account_type); + self.note_account_add_rewind(before); + added } #[cfg(feature = "eddsa")] @@ -332,8 +382,31 @@ impl ManagedAccountOperations for PlatformWalletInfo { account_type: AccountType, ed25519_public_key: [u8; 32], ) -> key_wallet::Result<()> { - self.core_wallet - .add_managed_eddsa_account_from_public_key(account_type, ed25519_public_key) + let before = self.core_wallet.metadata.synced_height; + let added = self + .core_wallet + .add_managed_eddsa_account_from_public_key(account_type, ed25519_public_key); + self.note_account_add_rewind(before); + added + } +} + +impl PlatformWalletInfo { + /// key-wallet's account-add API rewinds the scan cursor to just below + /// wallet birth (the new account has no filter coverage). That lowering + /// emits nothing and is not persisted here, so treat it like any other + /// in-memory rewind: advances queued before it must not be stored after + /// it, and the next DashPay backfill record round owes the host the + /// lowered cursor — otherwise a record-only round could store coverage + /// beside a host cursor that never came down (dashpay/platform#4302 + /// review). + fn note_account_add_rewind(&mut self, before: u32) { + let after = self.core_wallet.metadata.synced_height; + if after < before { + self.rewind_barrier.arm(); + let epoch = self.rewind_barrier.epoch(); + self.dashpay_backfill.owe_cursor(after, epoch); + } } } diff --git a/packages/rs-platform-wallet/src/wallet/provider_ecdsa_key_tests.rs b/packages/rs-platform-wallet/src/wallet/provider_ecdsa_key_tests.rs index 6cd3bd07510..da78ae53ba3 100644 --- a/packages/rs-platform-wallet/src/wallet/provider_ecdsa_key_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/provider_ecdsa_key_tests.rs @@ -45,6 +45,8 @@ async fn platform_wallet() -> PlatformWallet { Arc::new(tokio::sync::Notify::new()), Arc::new(NoopTestPersister) as Arc, Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), ) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index 670eaef352a..b207cacf648 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -188,6 +188,8 @@ where Arc::new(tokio::sync::Notify::new()), persister as Arc, Arc::new(crate::broadcaster::SpvBroadcaster::new(spv)), + Arc::new(std::sync::atomic::AtomicBool::new(false)), + crate::changeset::DurableCursors::default(), ) } diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 95d71c125b3..da5d40e8f04 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -54,13 +54,14 @@ use jni::JNIEnv; use platform_wallet_ffi::{ AccountAddressPoolFFI, AccountChangeSetFFI, AccountSpecFFI, AddressBalanceEntryFFI, AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, - ContactRequestRemovalFFI, CoreAddressEntryFFI, DpnsNameStateFFI, IdentityEntryFFI, - IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, - InvitationEntryFFI, OutPointFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, - PersistenceCallbacksExtension, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, - SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, - TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, - WalletChangeSetFFI, WalletRestoreEntryFFI, + ContactRequestRemovalFFI, CoreAddressEntryFFI, DashPayBackfillCoveredContactFFI, + DpnsNameStateFFI, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, + IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, InvitationEntryFFI, OutPointFFI, + PaymentRestoreEntryFFI, PersistenceCallbacks, PersistenceCallbacksExtension, + PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, SweepBatchFFI, + TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, + UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, + WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -205,11 +206,108 @@ pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks { /// sync watermark with it, and the round is refused one layer up instead of /// advancing past a removal that never happened. Wiring the slot for every /// subclass would make "slot present" prove nothing. +/// +/// The DashPay backfill slot is likewise wired only when the bridge +/// OVERRIDES `onWalletChangesetHeader`, the callback that stores the cursor. +/// `on_persist_wallet_changeset_fn` is always wired, so Rust's "cursor +/// delivered" gate cannot tell a stored cursor from one handed to the +/// inherited no-op header. A bridge that persists the backfill record but +/// not the cursor would then commit coverage for a rewind while keeping the +/// old cursor, and that coverage would suppress the recovery scan after a +/// restart (dashpay/platform#4302 review). Without the slot the record is +/// never delivered and every launch re-runs the backfill: slow, never lossy. +/// +/// It also needs the bridge to OVERRIDE `onWalletChangesetDashPayBackfill` +/// itself. The slot's presence is what `FFIPersister` reports as "this host +/// stores the record", and an inherited no-op would acknowledge the empty +/// record Rust sends to clear coverage the host handed back — so the +/// coverage would stay on disk while the wallet is published without it. +/// With the slot unwired that invalidation is refused instead, and the +/// publication fails closed. pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> PersistenceCallbacksExtension { - let sweeps_overridden = bridge_overrides(env, bridge, "onWalletChangesetTransactionsSwept"); + extension_for(BridgeOverrides { + sweeps: bridge_overrides( + env, + bridge, + "onWalletChangesetTransactionsSwept", + WALLET_CHANGESET_SWEEPS_DESCRIPTOR, + ), + header: bridge_overrides( + env, + bridge, + "onWalletChangesetHeader", + WALLET_CHANGESET_HEADER_DESCRIPTOR, + ), + backfill: bridge_overrides( + env, + bridge, + "onWalletChangesetDashPayBackfill", + WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR, + ), + }) +} + +/// Leave the account-registration and address-pool slots of `vtable` unwired +/// when the concrete `bridge` inherits `NativePersistenceBridge`'s +/// success-returning no-op for them (see [`bridge_overrides`]). +/// +/// Those slots are how `FFIPersister` derives `ACCOUNT_ADDRESS_POOLS`, and a +/// contact's outbound-account marker is kept on disk only where that +/// capability holds +/// ([`persists_outbound_account_markers`](platform_wallet::changeset::persists_outbound_account_markers)). +/// An inherited no-op would acknowledge an account it never stored, so the +/// marker naming that account could be trusted after a restart beside the +/// previous one (dashpay/platform#4302 review). Unwired, the capability is not +/// derived, account writes are reported as unsupported rather than silently +/// dropped, and markers stay in memory. +pub(crate) fn restrict_account_writers( + env: &mut JNIEnv, + bridge: &JObject, + vtable: &mut PersistenceCallbacks, +) { + let registrations = bridge_overrides( + env, + bridge, + "onPersistAccountRegistration", + ACCOUNT_REGISTRATION_DESCRIPTOR, + ); + let pools = bridge_overrides( + env, + bridge, + "onPersistAccountAddressPoolEntry", + ACCOUNT_ADDRESS_POOL_ENTRY_DESCRIPTOR, + ); + gate_account_writers(vtable, registrations, pools); +} + +/// The slot half of [`restrict_account_writers`]. +fn gate_account_writers(vtable: &mut PersistenceCallbacks, registrations: bool, pools: bool) { + if !registrations { + vtable.on_persist_account_registrations_fn = None; + } + if !pools { + vtable.on_persist_account_address_pools_fn = None; + } +} + +/// Which optional `NativePersistenceBridge` callbacks the concrete bridge +/// supplies its own body for (see [`bridge_overrides`]). +#[derive(Debug, Clone, Copy)] +struct BridgeOverrides { + /// `onWalletChangesetTransactionsSwept`. + sweeps: bool, + /// `onWalletChangesetHeader`, which carries the scan cursor. + header: bool, + /// `onWalletChangesetDashPayBackfill`, which stores the record. + backfill: bool, +} + +/// The extension slots wired for a bridge with `overrides` (see +/// [`build_extension`]). +fn extension_for(overrides: BridgeOverrides) -> PersistenceCallbacksExtension { PersistenceCallbacksExtension { on_persist_dpns_name_states_fn: Some(tramp_persist_dpns_name_states), - on_persist_wallet_changeset_sweeps_fn: if sweeps_overridden { + on_persist_wallet_changeset_sweeps_fn: if overrides.sweeps { Some(tramp_persist_wallet_changeset_sweeps) } else { None @@ -217,20 +315,41 @@ pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> Persistence on_persist_wallet_changeset_chain_lock_height_fn: Some( tramp_persist_wallet_changeset_chain_lock_height, ), + on_persist_wallet_dashpay_backfill_fn: if overrides.header && overrides.backfill { + Some(tramp_persist_wallet_dashpay_backfill) + } else { + None + }, ..Default::default() } } /// Whether `bridge`'s concrete class — or any superclass strictly below -/// `NativePersistenceBridge` — declares a method named `name`. A Kotlin -/// `override fun` is a declared method of the overriding class, so walking -/// `getDeclaredMethods()` up the hierarchy until the abstract bridge answers -/// "did a subclass supply its own body". Any JNI failure counts as "not -/// overridden" (the pending exception is cleared): the consequence is a -/// slot left unwired, which Rust turns into a stripped capability — the -/// safe direction, never a silently swallowed removal. -fn bridge_overrides(env: &mut JNIEnv, bridge: &JObject, name: &str) -> bool { - fn probe(env: &mut JNIEnv, bridge: &JObject, name: &str) -> Result { +/// `NativePersistenceBridge` — declares the method `name` with the JNI +/// `descriptor` the trampoline calls. A Kotlin `override fun` is a declared +/// method of the overriding class, so walking `getDeclaredMethods()` up the +/// hierarchy until the abstract bridge answers "did a subclass supply its own +/// body". The descriptor has to match, not just the name: an overload such +/// as `onWalletChangesetHeader(walletId, height)` leaves the callback the +/// trampoline invokes inherited, and must not count. Any JNI failure counts +/// as "not overridden" (the pending exception is cleared): the consequence is +/// a slot left unwired, which Rust turns into a stripped capability — the +/// safe direction, never a silently swallowed write. +fn bridge_overrides(env: &mut JNIEnv, bridge: &JObject, name: &str, descriptor: &str) -> bool { + fn class_name(env: &mut JNIEnv, class: &JObject) -> Result { + let name: JString = env + .call_method(class, "getName", "()Ljava/lang/String;", &[])? + .l()? + .into(); + let name = env.get_string(&name)?.to_str().map(str::to_owned); + Ok(name.unwrap_or_default()) + } + fn probe( + env: &mut JNIEnv, + bridge: &JObject, + name: &str, + descriptor: &str, + ) -> Result { let base = env.find_class("org/dashfoundation/dashsdk/ffi/NativePersistenceBridge")?; let mut class = env.get_object_class(bridge)?; loop { @@ -253,12 +372,28 @@ fn bridge_overrides(env: &mut JNIEnv, bridge: &JObject, name: &str) -> bool { .call_method(&method, "getName", "()Ljava/lang/String;", &[])? .l()? .into(); - let matches = env + let same_name = env .get_string(&method_name)? .to_str() .map(|s| s == name) .unwrap_or(false); - if matches { + if !same_name { + continue; + } + let params: JObjectArray = env + .call_method(&method, "getParameterTypes", "()[Ljava/lang/Class;", &[])? + .l()? + .into(); + let mut param_names = Vec::new(); + for p in 0..env.get_array_length(¶ms)? { + let param = env.get_object_array_element(¶ms, p)?; + param_names.push(class_name(env, ¶m)?); + } + let return_type = env + .call_method(&method, "getReturnType", "()Ljava/lang/Class;", &[])? + .l()?; + let return_name = class_name(env, &return_type)?; + if method_descriptor(¶m_names, &return_name) == descriptor { return Ok(true); } } @@ -271,7 +406,7 @@ fn bridge_overrides(env: &mut JNIEnv, bridge: &JObject, name: &str) -> bool { class = superclass.into(); } } - match probe(env, bridge, name) { + match probe(env, bridge, name, descriptor) { Ok(overridden) => overridden, Err(_) => { let _ = env.exception_clear(); @@ -280,6 +415,34 @@ fn bridge_overrides(env: &mut JNIEnv, bridge: &JObject, name: &str) -> bool { } } +/// The JNI descriptor of a method whose parameter and return types +/// `Class.getName()` reports as `params` and `return_type` — `int`, `[B`, +/// `java.lang.String`, `[Ljava.lang.String;` and so on. +fn method_descriptor(params: &[String], return_type: &str) -> String { + fn type_descriptor(name: &str) -> String { + match name { + "boolean" => "Z".to_owned(), + "byte" => "B".to_owned(), + "char" => "C".to_owned(), + "short" => "S".to_owned(), + "int" => "I".to_owned(), + "long" => "J".to_owned(), + "float" => "F".to_owned(), + "double" => "D".to_owned(), + "void" => "V".to_owned(), + array if array.starts_with('[') => array.replace('.', "/"), + class => format!("L{};", class.replace('.', "/")), + } + } + let mut descriptor = String::from("("); + for param in params { + descriptor.push_str(&type_descriptor(param)); + } + descriptor.push(')'); + descriptor.push_str(&type_descriptor(return_type)); + descriptor +} + /// `release_fn` for the persistence vtable: frees the boxed /// [`KotlinPersistenceCtx`] when the native manager's last persister /// reference drops. The FFI guarantees exactly one call, which may land @@ -585,7 +748,7 @@ fn call_persist_account_registration( env.call_method( bridge, "onPersistAccountRegistration", - "([BBBIII[B[B[B)I", + ACCOUNT_REGISTRATION_DESCRIPTOR, &[ wid.into(), JValue::Byte(s.type_tag as i8), @@ -629,7 +792,7 @@ unsafe extern "C" fn tramp_persist_account_address_pools( env.call_method( bridge, "onPersistAccountAddressPoolEntry", - "([BBBIII[B[BB[BZBIZJLjava/lang/String;Ljava/lang/String;)I", + ACCOUNT_ADDRESS_POOL_ENTRY_DESCRIPTOR, &[ (&wid).into(), JValue::Byte(spec.type_tag as i8), @@ -690,7 +853,7 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( .call_method( bridge, "onWalletChangesetHeader", - "([BZIZJJJJ[B)I", + WALLET_CHANGESET_HEADER_DESCRIPTOR, &[ (&wid).into(), JValue::Bool(has_synced as u8), @@ -776,6 +939,20 @@ unsafe extern "C" fn tramp_persist_wallet_changeset_sweeps( /// header's `(hasSyncedHeight, syncedHeight)`, not a sentinel. const WALLET_CHANGESET_SWEEPS_DESCRIPTOR: &str = "([B[BI[B[BIZI)I"; +/// `onWalletChangesetHeader`'s descriptor, shared by the `call_method` site, +/// the descriptor table and the override probe that gates the DashPay +/// backfill slot on it, so the three cannot drift apart. +const WALLET_CHANGESET_HEADER_DESCRIPTOR: &str = "([BZIZJJJJ[B)I"; + +/// `onPersistAccountRegistration`'s descriptor, shared by the `call_method` +/// site, the descriptor table and the override probe in +/// [`restrict_account_writers`]. +const ACCOUNT_REGISTRATION_DESCRIPTOR: &str = "([BBBIII[B[B[B)I"; + +/// `onPersistAccountAddressPoolEntry`'s descriptor, shared the same way. +const ACCOUNT_ADDRESS_POOL_ENTRY_DESCRIPTOR: &str = + "([BBBIII[B[BB[BZBIZJLjava/lang/String;Ljava/lang/String;)I"; + unsafe fn persist_changeset_sweep_batch( env: &mut JNIEnv, bridge: &JObject, @@ -850,6 +1027,63 @@ unsafe extern "C" fn tramp_persist_wallet_changeset_chain_lock_height( }) } +/// Wire size of one covered contact in the flat `covered` array handed to +/// `onWalletChangesetDashPayBackfill` and read back from +/// `WalletRestoreData.dashPayBackfillCovered`: owner (32) ‖ contact (32) ‖ +/// `accountIndex` (u32) ‖ `coveredFrom` (u32), both little-endian. The same +/// layout as +/// `DashPayBackfillRecord::covered_bytes`, so the handler can store the array +/// as one opaque blob and hand it straight back at load. +const DASHPAY_BACKFILL_COVERED_ENTRY_LEN: usize = 32 + 32 + 4 + 4; + +/// Descriptor of `NativePersistenceBridge.onWalletChangesetDashPayBackfill`: +/// `(walletId, floor, rewoundFrom, covered, coveredCount)`. The cover set is +/// shipped as ONE flat `byte[]` of `72·N` bytes plus a count, the same packing +/// the sweep slot uses for its txids, rather than one JVM allocation per +/// contact — a contact-heavy wallet records hundreds. +const WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR: &str = "([BII[BI)I"; + +/// Deliver the wallet's DashPay backfill record (see +/// `PersistWalletDashPayBackfillFn`) — the durable half of the coreHeight +/// rescan guard (dashpay/platform#4302). Whole-record semantics: the handler +/// replaces what it holds on the wallet row. +unsafe extern "C" fn tramp_persist_wallet_dashpay_backfill( + context: *mut c_void, + wallet_id: *const u8, + floor: u32, + rewound_from: u32, + covered: *const DashPayBackfillCoveredContactFFI, + covered_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + let entries = slice_or_empty(covered, covered_count); + let mut packed = Vec::with_capacity(entries.len() * DASHPAY_BACKFILL_COVERED_ENTRY_LEN); + for entry in entries { + packed.extend_from_slice(&entry.owner_identity_id); + packed.extend_from_slice(&entry.contact_identity_id); + packed.extend_from_slice(&entry.account_index.to_le_bytes()); + packed.extend_from_slice(&entry.covered_from.to_le_bytes()); + } + let packed_arr = env.byte_array_from_slice(&packed)?; + let count = i32::try_from(entries.len()) + .map_err(|_| jni::errors::Error::JniCall(jni::errors::JniError::InvalidArguments))?; + env.call_method( + bridge, + "onWalletChangesetDashPayBackfill", + WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR, + &[ + (&wid).into(), + JValue::Int(jint_height(floor)?), + JValue::Int(jint_height(rewound_from)?), + (&packed_arr).into(), + JValue::Int(count), + ], + )? + .i() + }) +} + unsafe fn persist_changeset_account( env: &mut JNIEnv, bridge: &JObject, @@ -1500,7 +1734,7 @@ unsafe fn persist_contact_upsert( env.call_method( bridge, "onPersistContactUpsert", - "([B[B[BZIII[B[B[BIJZLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;[I)I", + CONTACT_UPSERT_DESCRIPTOR, &[ wid.into(), (&owner).into(), @@ -1520,11 +1754,25 @@ unsafe fn persist_contact_upsert( JValue::Bool(c.is_hidden as u8), (&contact_account_label).into(), (&accepted).into(), + JValue::Bool(c.has_external_account_reference as u8), + JValue::Int(if c.has_external_account_reference { + c.external_account_reference as i32 + } else { + 0 + }), ], )? .i() } +/// Descriptor of `NativePersistenceBridge.onPersistContactUpsert`. The +/// trailing `(Z, I)` pair is `EstablishedContact::external_account_reference` +/// as `(hasExternalAccountReference, externalAccountReference)` — the marker +/// whose loss across restart rebuilt every outbound contact account on every +/// launch (dashpay/platform#4302). +const CONTACT_UPSERT_DESCRIPTOR: &str = + "([B[B[BZIII[B[B[BIJZLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;[IZI)I"; + /// Copy `len` `u32`s from `ptr` (or 0 when null) into a JVM `int[]` /// (bit-pattern cast — DIP-15 account indices never exceed `i32::MAX` /// in practice, and the Kotlin side reads them back with the same cast). @@ -1973,6 +2221,11 @@ struct WalletRestoreStaged { /// minted null / 0 at seal (no chainlock persisted). A single flat /// buffer, freed with one `free_raw_bytes`. last_applied_chain_lock: Vec, + /// The DashPay backfill record's cover set. `Copy` POD like the + /// platform-address balances: minted in one shot at seal, freed with a + /// single `free_raw_slice`. The record's scalars and presence flag ride + /// on `entry` directly. + dashpay_backfill_covered: Vec, } /// Staged account spec: FFI struct with a null xpub pointer plus the @@ -2169,6 +2422,7 @@ fn seal_wallet_entries(staged: Vec) -> Vec` mints in one shot and @@ -2291,6 +2545,13 @@ fn seal_wallet_entries(staged: Vec) -> Vec Result<(bool, u32, u32, Vec), jni::errors::Error> { + let present = env.get_field(holder, "hasDashPayBackfill", "Z")?.z()?; + if !present { + return Ok((false, 0, 0, Vec::new())); + } + let floor = env.get_field(holder, "dashPayBackfillFloor", "I")?.i()?; + let rewound_from = env + .get_field(holder, "dashPayBackfillRewoundFrom", "I")? + .i()?; + let blob = read_bytes_field_vec(env, holder, "dashPayBackfillCovered")?; + if floor < 0 + || rewound_from < 0 + || !blob + .len() + .is_multiple_of(DASHPAY_BACKFILL_COVERED_ENTRY_LEN) + { + log::warn!( + "load: malformed DashPay backfill record on the wallet row (floor={floor}, \ + rewound_from={rewound_from}, blob_len={}); reading as no record", + blob.len() + ); + return Ok((false, 0, 0, Vec::new())); + } + let (chunks, _) = blob.as_chunks::(); + let covered = chunks + .iter() + .map(|chunk| { + let mut owner_identity_id = [0u8; 32]; + let mut contact_identity_id = [0u8; 32]; + let mut account_index = [0u8; 4]; + let mut height = [0u8; 4]; + owner_identity_id.copy_from_slice(&chunk[..32]); + contact_identity_id.copy_from_slice(&chunk[32..64]); + account_index.copy_from_slice(&chunk[64..68]); + height.copy_from_slice(&chunk[68..72]); + DashPayBackfillCoveredContactFFI { + owner_identity_id, + contact_identity_id, + account_index: u32::from_le_bytes(account_index), + covered_from: u32::from_le_bytes(height), + } + }) + .collect(); + Ok((true, floor as u32, rewound_from as u32, covered)) +} + /// Read the Kotlin `WalletRestoreData.utxos` array into staged /// [`UtxoRestoreStaged`] rows (CORE-06 restore path). Empty / null field /// → empty vec (staged as `(null, 0)` at seal). @@ -3323,6 +3657,15 @@ fn build_contact_restore( let is_hidden = env.get_field(holder, "isHidden", "Z")?.z()?; let contact_account_label = read_opt_cstring_field(env, holder, "contactAccountLabel")?; let accepted_accounts = read_u32_array_field(env, holder, "acceptedAccounts")?; + let has_external_account_reference = env + .get_field(holder, "hasExternalAccountReference", "Z")? + .z()?; + let external_account_reference = if has_external_account_reference { + env.get_field(holder, "externalAccountReference", "I")? + .i()? as u32 + } else { + 0 + }; let row = ContactRequestFFI { owner_id, @@ -3346,6 +3689,8 @@ fn build_contact_restore( contact_account_label: ptr::null(), accepted_accounts: ptr::null(), accepted_accounts_len: 0, + has_external_account_reference, + external_account_reference, }; Ok(ContactRestoreStaged { row, @@ -3613,6 +3958,9 @@ unsafe extern "C" fn tramp_load_wallet_list_free( e.last_applied_chain_lock_bytes_len, ); + // DashPay backfill cover set — flat POD slice, one mint at seal. + free_raw_slice(e.dashpay_backfill_covered, e.dashpay_backfill_covered_count); + // identities + nested key / contact / ignored-sender / // payment / contact-profile arrays (each key's `data` buffer + // contract-bounds doc-type C-string; each contact's three byte @@ -4516,12 +4864,18 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ ("onPersistAddressBalance", "([BB[BJIIIJ)I"), ("onPersistSyncState", "([BJJJ)I"), ("onPersistWalletMetadata", "([BI[BI)I"), - ("onPersistAccountRegistration", "([BBBIII[B[B[B)I"), + ( + "onPersistAccountRegistration", + ACCOUNT_REGISTRATION_DESCRIPTOR, + ), ( "onPersistAccountAddressPoolEntry", - "([BBBIII[B[BB[BZBIZJLjava/lang/String;Ljava/lang/String;)I", + ACCOUNT_ADDRESS_POOL_ENTRY_DESCRIPTOR, + ), + ( + "onWalletChangesetHeader", + WALLET_CHANGESET_HEADER_DESCRIPTOR, ), - ("onWalletChangesetHeader", "([BZIZJJJJ[B)I"), ("onWalletChangesetAccountBegin", "([BIBBII[B[BIZIZ)I"), ("onWalletChangesetAccountEnd", "([BI)I"), ( @@ -4548,6 +4902,10 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ // literal at the `call_method` site in // `tramp_persist_wallet_changeset_chain_lock_height`. ("onWalletChangesetChainLockHeight", "([BI)I"), + ( + "onWalletChangesetDashPayBackfill", + WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR, + ), ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ @@ -4564,10 +4922,7 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ ("onPersistTokenBalanceUpsert", "([B[B[BJ)I"), ("onPersistTokenBalanceRemoval", "([B[B[B)I"), ("onPersistContactIgnored", "([B[B[BZ)I"), - ( - "onPersistContactUpsert", - "([B[B[BZIII[B[B[BIJZLjava/lang/String;Ljava/lang/String;ZLjava/lang/String;[I)I", - ), + ("onPersistContactUpsert", CONTACT_UPSERT_DESCRIPTOR), ("onPersistContactRemovalSent", "([B[B[B)I"), ("onPersistContactRemovalIncoming", "([B[B[B)I"), ("onPersistAssetLockUpsert", "([B[B[BIBIJB[B)I"), @@ -4723,6 +5078,111 @@ mod tests { /// A height past `i32::MAX` must refuse the call, never wrap into a /// negative `Int` the handler would read as absent or as a bogus /// collection boundary. + /// The override probe compares the reflected descriptor, not the name: an + /// overload of `onWalletChangesetHeader` leaves the callback the + /// trampoline invokes inherited, so it must not wire the backfill slot. + #[test] + fn the_override_probe_matches_the_called_descriptor_not_an_overload() { + let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::>(); + let header = names(&[ + "[B", "boolean", "int", "boolean", "long", "long", "long", "long", "[B", + ]); + assert_eq!( + method_descriptor(&header, "int"), + WALLET_CHANGESET_HEADER_DESCRIPTOR, + "the real override resolves to the descriptor the trampoline calls" + ); + assert_ne!( + method_descriptor(&names(&["[B", "int"]), "int"), + WALLET_CHANGESET_HEADER_DESCRIPTOR, + "an overload is not the callback" + ); + let sweeps = names(&["[B", "[B", "int", "[B", "[B", "int", "boolean", "int"]); + assert_eq!( + method_descriptor(&sweeps, "int"), + WALLET_CHANGESET_SWEEPS_DESCRIPTOR + ); + assert_eq!( + method_descriptor(&names(&["java.lang.String", "[Ljava.lang.String;"]), "void"), + "(Ljava/lang/String;[Ljava/lang/String;)V" + ); + } + + /// The backfill slot needs both callbacks a bridge supplies its own body + /// for. One that inherits the no-op `onWalletChangesetHeader` stores no + /// cursor, so coverage would land beside a cursor that never moved. One + /// that inherits the no-op `onWalletChangesetDashPayBackfill` stores no + /// record, yet would acknowledge the invalidation of coverage it handed + /// back on load. Left unwired, `FFIPersister` reports that the host does + /// not store the record and refuses that invalidation, so the publication + /// fails closed (dashpay/platform#4302 review). The sweep slot keeps its + /// own probe. + #[test] + fn the_backfill_slot_is_wired_only_for_a_bridge_that_stores_cursor_and_record() { + for (header, backfill, wired) in [ + (false, false, false), + (false, true, false), + (true, false, false), + (true, true, true), + ] { + let extension = extension_for(BridgeOverrides { + sweeps: false, + header, + backfill, + }); + assert_eq!( + extension.on_persist_wallet_dashpay_backfill_fn.is_some(), + wired, + "header overridden: {header}, backfill overridden: {backfill}" + ); + } + let sweeps_only = extension_for(BridgeOverrides { + sweeps: true, + header: false, + backfill: false, + }); + assert!(sweeps_only.on_persist_wallet_changeset_sweeps_fn.is_some()); + assert!(sweeps_only.on_persist_wallet_dashpay_backfill_fn.is_none()); + } + + /// A bridge inheriting the no-op account writers gets neither account + /// slot, so `FFIPersister` cannot derive `ACCOUNT_ADDRESS_POOLS` and does + /// not keep outbound-account markers on disk; one implementing both keeps + /// both slots. + #[test] + fn account_writer_slots_follow_the_bridges_overrides() { + for (registrations, pools) in [(false, false), (true, false), (false, true), (true, true)] { + let mut vtable = build_vtable(std::ptr::null_mut()); + gate_account_writers(&mut vtable, registrations, pools); + assert_eq!( + vtable.on_persist_account_registrations_fn.is_some(), + registrations + ); + assert_eq!(vtable.on_persist_account_address_pools_fn.is_some(), pools); + // `release_fn` must not run on the null context when this drops. + vtable.release_fn = None; + } + let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::>(); + assert_eq!( + method_descriptor( + &names(&["[B", "byte", "byte", "int", "int", "int", "[B", "[B", "[B"]), + "int" + ), + ACCOUNT_REGISTRATION_DESCRIPTOR + ); + } + + /// The probe has to recognise a real `onWalletChangesetDashPayBackfill` + /// override by the descriptor the trampoline calls. + #[test] + fn the_backfill_probe_matches_the_called_descriptor() { + let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::>(); + assert_eq!( + method_descriptor(&names(&["[B", "int", "int", "[B", "int"]), "int"), + WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR + ); + } + #[test] fn a_height_past_i32_max_is_refused_rather_than_wrapped() { assert_eq!(jint_height(0).unwrap(), 0); diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 10396a29939..99eba41dbd0 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -41,7 +41,9 @@ #![allow(clippy::missing_safety_doc)] use crate::events::{build_event_extension, build_event_vtable, KotlinEventCtx}; -use crate::persistence::{build_extension, build_vtable, KotlinPersistenceCtx}; +use crate::persistence::{ + build_extension, build_vtable, restrict_account_writers, KotlinPersistenceCtx, +}; use crate::support::{guard, take_pwffi_error, throw_pwffi_result, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JObject, JObjectArray, JString, JValue}; use jni::sys::{ @@ -154,7 +156,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_n }; let persistence_ctx = Box::into_raw(Box::new(KotlinPersistenceCtx::new(persistence_global))); - let persistence: PersistenceCallbacks = build_vtable(persistence_ctx as *mut c_void); + let mut persistence: PersistenceCallbacks = build_vtable(persistence_ctx as *mut c_void); + restrict_account_writers(env, &persistence_bridge, &mut persistence); let persistence_extension = build_extension(env, &persistence_bridge); let persistence_capabilities = PersistenceCapabilitiesFFI { version: declared_capabilities_version, diff --git a/packages/token-history-contract/package.json b/packages/token-history-contract/package.json index 51f57bb017c..b5666b70579 100644 --- a/packages/token-history-contract/package.json +++ b/packages/token-history-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/token-history-contract", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "The token history contract", "scripts": { "lint": "eslint .", diff --git a/packages/wallet-lib/package.json b/packages/wallet-lib/package.json index a0c019e366b..de1d8a75f08 100644 --- a/packages/wallet-lib/package.json +++ b/packages/wallet-lib/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wallet-lib", - "version": "12.0.0-rc.3", + "version": "12.0.0-rc.4", "description": "Light wallet library for Dash", "main": "src/index.js", "unpkg": "dist/wallet-lib.min.js", diff --git a/packages/wallet-utils-contract/package.json b/packages/wallet-utils-contract/package.json index e434896afd1..662a4a30450 100644 --- a/packages/wallet-utils-contract/package.json +++ b/packages/wallet-utils-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wallet-utils-contract", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "A contract and helper scripts for Wallet DApp", "scripts": { "lint": "eslint .", diff --git a/packages/wasm-dpp/package.json b/packages/wasm-dpp/package.json index c74e7405c9d..e6c636e35f7 100644 --- a/packages/wasm-dpp/package.json +++ b/packages/wasm-dpp/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wasm-dpp", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "The JavaScript implementation of the Dash Platform Protocol", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/wasm-dpp2/package.json b/packages/wasm-dpp2/package.json index 5b9a05e0c30..4187e91fb94 100644 --- a/packages/wasm-dpp2/package.json +++ b/packages/wasm-dpp2/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wasm-dpp2", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "type": "module", "main": "./dist/dpp.js", "types": "./dist/dpp.d.ts", diff --git a/packages/wasm-drive-verify/package.json b/packages/wasm-drive-verify/package.json index facd0deedd2..d0a0bd62a51 100644 --- a/packages/wasm-drive-verify/package.json +++ b/packages/wasm-drive-verify/package.json @@ -3,7 +3,7 @@ "collaborators": [ "Dash Core Group " ], - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "license": "MIT", "description": "WASM bindings for Drive verify functions", "repository": { diff --git a/packages/wasm-sdk/package.json b/packages/wasm-sdk/package.json index 423160ebee1..66119c48430 100644 --- a/packages/wasm-sdk/package.json +++ b/packages/wasm-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wasm-sdk", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "type": "module", "main": "./dist/sdk.js", "types": "./dist/sdk.d.ts", diff --git a/packages/withdrawals-contract/package.json b/packages/withdrawals-contract/package.json index cf6d12c9558..061e45cb4ce 100644 --- a/packages/withdrawals-contract/package.json +++ b/packages/withdrawals-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/withdrawals-contract", - "version": "5.0.0-rc.3", + "version": "5.0.0-rc.4", "description": "Data Contract to manipulate and track withdrawals", "scripts": { "build": "", diff --git a/yarn.lock b/yarn.lock index 591f83ff3dd..2dfa97f37fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1606,7 +1606,7 @@ __metadata: "@dashevo/dapi-client": "workspace:*" "@dashevo/dapi-grpc": "workspace:*" "@dashevo/dashcore-lib": "npm:~0.22.0" - "@dashevo/dashd-rpc": "npm:^19.0.0" + "@dashevo/dashd-rpc": "npm:^19.1.0" "@dashevo/dp-services-ctl": "github:dashevo/js-dp-services-ctl#v0.19-dev" "@dashevo/grpc-common": "workspace:*" "@dashevo/wasm-dpp": "workspace:*" @@ -1694,13 +1694,13 @@ __metadata: languageName: node linkType: hard -"@dashevo/dashd-rpc@npm:^19.0.0": - version: 19.0.0 - resolution: "@dashevo/dashd-rpc@npm:19.0.0" +"@dashevo/dashd-rpc@npm:^19.1.0": + version: 19.1.0 + resolution: "@dashevo/dashd-rpc@npm:19.1.0" dependencies: async: "npm:^3.2.4" bluebird: "npm:^3.7.2" - checksum: 10/2eab84af3e9e654601b6bdf02b670352e4a6aec5ee0cdb3b4d0fd81655ff9b4792dbec0fd02cdc72dc54b1867103f53e17264cf5b966a4c51f2d691088d783d1 + checksum: 10/5d3ebf1f0b1a5547946ca090aa98ad18ca291c811bc40ff9d8e532d0291ea7b1592423e49ed7946da06bb344ebee8065712a6c0be38c6c27e3407588c78367e0 languageName: node linkType: hard @@ -7342,7 +7342,7 @@ __metadata: "@dashevo/bls": "npm:~1.2.9" "@dashevo/dapi-client": "workspace:*" "@dashevo/dashcore-lib": "npm:~0.22.0" - "@dashevo/dashd-rpc": "npm:^19.0.0" + "@dashevo/dashd-rpc": "npm:^19.1.0" "@dashevo/docker-compose": "npm:^0.24.4" "@dashevo/wallet-lib": "workspace:*" "@dashevo/withdrawals-contract": "workspace:*"