diff --git a/Cargo.lock b/Cargo.lock index 8ad8efa2be1..6910cebbebf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,17 +29,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - [[package]] name = "ahash" version = "0.8.12" @@ -56,9 +45,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -88,9 +77,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -153,9 +142,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "apple-native-keyring-store" @@ -179,9 +168,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -206,9 +195,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "assert_cmd" @@ -261,18 +250,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -311,9 +300,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -321,14 +310,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -439,7 +429,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -466,9 +456,9 @@ checksum = "6107fe1be6682a68940da878d9e9f5e90ca5745b3dec9fd1bb393c8777d4f581" [[package]] name = "base58ck" -version = "0.1.100" +version = "0.1.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5dc7e09f7bb15f0062da7c03086d6b71a2c84e0af4fccbbc7d8c6559847816" +checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0" dependencies = [ "bitcoin_hashes", ] @@ -481,15 +471,15 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" -version = "0.21.7" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "base64-compat" @@ -563,7 +553,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex 1.3.0", - "syn 2.0.117", + "syn 2.0.119", "which", ] @@ -573,16 +563,16 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "regex", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "shlex 1.3.0", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -602,7 +592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", - "rand 0.8.6", + "rand 0.8.8", "rand_core 0.6.4", "serde", "unicode-normalization", @@ -639,20 +629,40 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + [[package]] name = "bitcoin-io" -version = "0.1.100" +version = "0.1.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] [[package]] name = "bitcoin_hashes" -version = "0.14.100" +version = "0.14.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.3", ] [[package]] @@ -663,15 +673,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -690,27 +700,25 @@ dependencies = [ [[package]] name = "blake2b_simd" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" dependencies = [ - "arrayref", "arrayvec", "constant_time_eq 0.4.2", ] [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq 0.4.2", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -748,7 +756,7 @@ source = "git+https://github.com/dashpay/bls-signatures?rev=0842b17583888e8f46c2 dependencies = [ "bls-dash-sys", "hex", - "rand 0.8.6", + "rand 0.8.8", "serde", ] @@ -774,7 +782,7 @@ dependencies = [ "hkdf", "merlin", "pairing", - "rand 0.8.6", + "rand 0.8.8", "rand_chacha 0.3.1", "rand_core 0.6.4", "serde", @@ -782,7 +790,7 @@ dependencies = [ "sha2", "sha3", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "uint-zigzag", "vsss-rs", "zeroize", @@ -820,9 +828,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ "borsh-derive", "bytes", @@ -831,15 +839,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.6.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" dependencies = [ "once_cell", "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -854,13 +862,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -869,28 +877,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "bytecount" version = "0.6.9" @@ -899,9 +885,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -917,9 +903,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -957,13 +943,13 @@ checksum = "3fce8dd7fcfcbf3a0a87d8f515194b49d6135acab73e18bd380d1d93bb1a15eb" dependencies = [ "clap", "heck 0.4.1", - "indexmap 2.14.0", + "indexmap 2.14.2", "log", "proc-macro2", "quote", "serde", "serde_json", - "syn 2.0.117", + "syn 2.0.119", "tempfile", "toml 0.8.23", ] @@ -976,22 +962,22 @@ checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" dependencies = [ "clap", "heck 0.5.0", - "indexmap 2.14.0", + "indexmap 2.14.2", "log", "proc-macro2", "quote", "serde", "serde_json", - "syn 2.0.117", + "syn 2.0.119", "tempfile", "toml 0.9.12+spec-1.1.0", ] [[package]] name = "cc" -version = "1.2.64" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -1022,9 +1008,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -1039,12 +1025,12 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -1144,9 +1130,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -1155,9 +1141,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -1165,9 +1151,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -1177,14 +1163,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -1208,7 +1194,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1234,23 +1220,14 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "console-api" version = "0.8.1" @@ -1376,9 +1353,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -1400,9 +1377,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1451,18 +1428,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1470,27 +1447,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -1546,7 +1523,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1598,7 +1575,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1611,7 +1588,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1622,7 +1599,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1633,7 +1610,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1641,7 +1618,7 @@ name = "dash-async" version = "4.2.0-dev.8" dependencies = [ "futures", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -1662,7 +1639,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1650,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "dash-network", ] @@ -1699,7 +1676,7 @@ version = "4.2.0-dev.8" dependencies = [ "heck 0.5.0", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1715,7 +1692,7 @@ dependencies = [ "hex", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -1756,7 +1733,7 @@ dependencies = [ "serde", "serde_json", "test-case", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-test", "tokio-util", @@ -1768,7 +1745,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "async-trait", "chrono", @@ -1781,7 +1758,7 @@ dependencies = [ "hex", "key-wallet", "key-wallet-manager", - "rand 0.8.6", + "rand 0.8.8", "rayon", "serde", "serde_json", @@ -1797,7 +1774,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "anyhow", "base64-compat", @@ -1816,19 +1793,19 @@ dependencies = [ "rustversion", "secp256k1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "dashcore-rpc-json", "hex", @@ -1841,7 +1818,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode", "dashcore", @@ -1856,7 +1833,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode", "dashcore-private", @@ -1871,7 +1848,7 @@ dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1886,7 +1863,7 @@ dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "token-history-contract", "wallet-utils-contract", "withdrawals-contract", @@ -1894,15 +1871,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", @@ -1938,6 +1915,37 @@ dependencies = [ "keyring-core", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "delegate" version = "0.13.5" @@ -1946,7 +1954,7 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1976,7 +1984,7 @@ checksum = "74ef43543e701c01ad77d3a5922755c6a1d71b22d942cb8042be4994b380caff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1987,7 +1995,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2016,7 +2024,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] @@ -2030,7 +2038,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2058,13 +2066,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -2083,7 +2091,7 @@ dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -2105,7 +2113,7 @@ dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -2134,7 +2142,7 @@ dependencies = [ "getrandom 0.2.17", "grovedb-commitment-tree", "hex", - "indexmap 2.14.0", + "indexmap 2.14.2", "integer-encoding", "itertools 0.13.0", "json-schema-compatibility-validator", @@ -2152,7 +2160,7 @@ dependencies = [ "platform-version", "platform-versioning", "pretty_assertions", - "rand 0.8.6", + "rand 0.8.8", "regex", "rust_decimal", "rust_decimal_macros", @@ -2161,7 +2169,7 @@ dependencies = [ "serde_repr", "sha2", "strum 0.26.3", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -2172,7 +2180,7 @@ version = "4.2.0-dev.8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2199,7 +2207,7 @@ dependencies = [ "grovedb-storage", "grovedb-version", "hex", - "indexmap 2.14.0", + "indexmap 2.14.2", "integer-encoding", "intmap", "itertools 0.13.0", @@ -2208,12 +2216,12 @@ dependencies = [ "once_cell", "parking_lot", "platform-version", - "rand 0.8.6", + "rand 0.8.8", "serde", "serde_json", "sqlparser", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -2247,7 +2255,7 @@ dependencies = [ "grovedb-path", "grovedb-storage", "hex", - "indexmap 2.14.0", + "indexmap 2.14.2", "integer-encoding", "itertools 0.13.0", "lazy_static", @@ -2258,7 +2266,7 @@ dependencies = [ "nonempty", "platform-version", "prost 0.14.4", - "rand 0.8.6", + "rand 0.8.8", "regex", "reopen", "rocksdb", @@ -2290,13 +2298,13 @@ dependencies = [ "dpp", "drive", "hex", - "indexmap 2.14.0", + "indexmap 2.14.2", "platform-serialization", "platform-serialization-derive", "serde", "serde_json", "tenderdash-abci", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -2360,9 +2368,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -2437,7 +2445,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2452,9 +2460,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -2462,13 +2470,13 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", - "env_filter 1.0.1", + "env_filter 2.0.0", "jiff", "log", ] @@ -2500,11 +2508,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2544,9 +2551,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fd-lock" @@ -2607,9 +2614,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fixedbitset" @@ -2619,12 +2626,12 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -2719,7 +2726,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serdect", - "thiserror 2.0.18", + "thiserror 2.0.20", "visibility", "zeroize", "zeroize_derive", @@ -2752,9 +2759,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2767,9 +2774,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2777,15 +2784,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2794,38 +2801,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2851,9 +2858,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.4.3" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2e55f16dcf0e9c00efbe2e655ffe45fc98e7066b52bc92f8a79e64060a79351" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" dependencies = [ "rustversion", "serde_core", @@ -2889,16 +2896,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", + "wasm-bindgen", ] [[package]] @@ -2909,7 +2916,7 @@ checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2925,19 +2932,19 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -2966,7 +2973,7 @@ checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", "memuse", - "rand 0.8.6", + "rand 0.8.8", "rand_core 0.6.4", "rand_xorshift 0.3.0", "subtle", @@ -2997,14 +3004,14 @@ dependencies = [ "grovedbg-types", "hex", "hex-literal", - "indexmap 2.14.0", + "indexmap 2.14.2", "integer-encoding", "intmap", "itertools 0.14.0", "reqwest 0.13.4", "sha2", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tower-http", @@ -3026,7 +3033,7 @@ dependencies = [ "grovedb-version", "hex", "integer-encoding", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3043,7 +3050,7 @@ dependencies = [ "orchard", "rusqlite", "shardtree", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3053,7 +3060,7 @@ source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd dependencies = [ "integer-encoding", "intmap", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3067,7 +3074,7 @@ dependencies = [ "grovedb-query", "grovedb-storage", "grovedb-version", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3083,7 +3090,7 @@ dependencies = [ "grovedb-visualize", "hex", "integer-encoding", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3095,7 +3102,7 @@ dependencies = [ "hex", "integer-encoding", "intmap", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3117,11 +3124,11 @@ dependencies = [ "grovedb-version", "grovedb-visualize", "hex", - "indexmap 2.14.0", + "indexmap 2.14.2", "integer-encoding", "num_cpus", - "rand 0.10.1", - "thiserror 2.0.18", + "rand 0.10.2", + "thiserror 2.0.20", ] [[package]] @@ -3155,7 +3162,7 @@ dependencies = [ "grovedb-costs", "grovedb-storage", "grovedb-version", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3169,9 +3176,9 @@ dependencies = [ "grovedb-costs", "grovedb-storage", "hex", - "indexmap 2.14.0", + "indexmap 2.14.2", "integer-encoding", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3190,7 +3197,7 @@ dependencies = [ "rocksdb", "strum 0.28.0", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3198,7 +3205,7 @@ name = "grovedb-version" version = "5.0.1" source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -3217,14 +3224,14 @@ version = "5.0.1" source = "git+https://github.com/dashpay/grovedb?rev=6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e#6fc7e1e82de27a56c3428c0d5dd15b5f6a0ea23e" dependencies = [ "serde", - "serde_with 3.21.0", + "serde_with 3.22.0", ] [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -3232,7 +3239,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -3264,7 +3271,7 @@ dependencies = [ "halo2_proofs", "lazy_static", "pasta_curves", - "rand 0.8.6", + "rand 0.8.8", "sinsemilla", "subtle", "uint", @@ -3290,9 +3297,9 @@ dependencies = [ [[package]] name = "halo2_proofs" -version = "0.3.2" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05713f117155643ce10975e0bee44a274bcda2f4bb5ef29a999ad67c1fa8d4d3" +checksum = "f5aca1c66059a919227dec97444a11a4350d2f9c820ca48690988f0aa0e81cbf" dependencies = [ "blake2b_simd", "ff", @@ -3328,9 +3335,6 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] [[package]] name = "hashbrown" @@ -3369,14 +3373,14 @@ dependencies = [ [[package]] name = "hdrhistogram" -version = "7.5.4" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "byteorder", "flate2", - "nom 7.1.3", + "nom 8.0.0", "num-traits", ] @@ -3390,7 +3394,7 @@ dependencies = [ "hash32 0.2.1", "rustc_version", "serde", - "spin 0.9.8", + "spin 0.9.9", "stable_deref_trait", ] @@ -3418,9 +3422,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -3433,9 +3437,18 @@ dependencies = [ [[package]] name = "hex-conservative" -version = "0.2.2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" dependencies = [ "arrayvec", ] @@ -3481,9 +3494,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -3491,9 +3504,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -3501,9 +3514,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -3542,15 +3555,15 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -3630,7 +3643,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -3664,9 +3677,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3678,9 +3691,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3691,9 +3704,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3705,16 +3718,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3725,15 +3739,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3744,12 +3758,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -3779,9 +3787,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.26" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -3832,9 +3840,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -3869,9 +3877,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-terminal" @@ -3892,9 +3900,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "iso8601" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +checksum = "74a0559b45528cf0732d911524974977a5749f477d7dd99652830ffdaf53c4d1" dependencies = [ "nom 8.0.0", ] @@ -3934,26 +3942,55 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.28" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ + "defmt", + "jiff-core", "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", ] [[package]] name = "jiff-static" -version = "0.2.28" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", ] [[package]] @@ -3984,7 +4021,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -3999,7 +4036,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4027,16 +4064,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -4070,7 +4107,7 @@ dependencies = [ "json-schema-compatibility-validator", "once_cell", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4089,7 +4126,7 @@ name = "jsonschema" version = "0.18.0" source = "git+https://github.com/dashpay/jsonschema-rs?branch=configure_regexp#aacc1ab5140daac30eb65d376852f01f5381979d" dependencies = [ - "ahash 0.8.12", + "ahash", "anyhow", "base64 0.22.1", "bytecount", @@ -4137,7 +4174,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "aes", "async-trait", @@ -4145,14 +4182,14 @@ dependencies = [ "bincode", "bincode_derive", "bip39", - "bitflags 2.13.0", + "bitflags 2.13.1", "bs58", "dashcore", "dashcore-private", "dashcore_hashes", "getrandom 0.2.17", "hex", - "rand 0.8.6", + "rand 0.8.8", "scrypt", "secp256k1", "serde", @@ -4166,7 +4203,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4182,7 +4219,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "async-trait", "bincode", @@ -4212,7 +4249,7 @@ dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4221,7 +4258,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin 0.9.9", ] [[package]] @@ -4230,12 +4267,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "lhash" version = "1.1.0" @@ -4244,9 +4275,9 @@ checksum = "744a4c881f502e98c2241d2e5f50040ac73b30194d64452bb6260393b53f0dc9" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -4324,9 +4355,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -4345,9 +4376,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" @@ -4400,7 +4431,7 @@ dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4436,9 +4467,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memuse" @@ -4478,7 +4509,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "indexmap 2.14.0", + "indexmap 2.14.2", "ipnet", "metrics", "metrics-util", @@ -4499,7 +4530,7 @@ dependencies = [ "hashbrown 0.15.5", "metrics", "quanta", - "rand 0.9.4", + "rand 0.9.5", "rand_xoshiro", "sketches-ddsketch", ] @@ -4522,9 +4553,9 @@ dependencies = [ [[package]] name = "minicov" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" dependencies = [ "cc", "walkdir", @@ -4546,11 +4577,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -4580,7 +4621,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4712,13 +4753,13 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", - "rand 0.8.6", + "rand 0.8.8", "serde", ] @@ -4735,7 +4776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", "serde", ] @@ -4753,25 +4794,24 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -4848,7 +4888,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4881,7 +4921,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "foreign-types", "libc", @@ -4897,7 +4937,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4950,7 +4990,7 @@ dependencies = [ "memuse", "nonempty", "pasta_curves", - "rand 0.8.6", + "rand 0.8.8", "rand_core 0.6.4", "reddsa", "serde", @@ -5023,15 +5063,15 @@ dependencies = [ [[package]] name = "pasta_curves" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e57598f73cc7e1b2ac63c79c517b31a0877cd7c402cdcaa311b5208de7a095" +checksum = "3437083215c505e867eea5478371feba43d7689d6d15ec0a209eb46fb0d4cda6" dependencies = [ "blake2b_simd", "ff", "group", "lazy_static", - "rand 0.8.6", + "rand 0.8.8", "static_assertions", "subtle", ] @@ -5072,7 +5112,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap 2.14.2", ] [[package]] @@ -5101,7 +5141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -5130,7 +5170,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5151,9 +5191,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "platform-encryption" @@ -5181,7 +5221,7 @@ version = "4.2.0-dev.8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "virtue 0.0.17", ] @@ -5194,13 +5234,13 @@ dependencies = [ "bs58", "ciborium", "hex", - "indexmap 2.14.0", + "indexmap 2.14.2", "platform-serialization", "platform-version", - "rand 0.8.6", + "rand 0.8.8", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "treediff", ] @@ -5209,7 +5249,7 @@ name = "platform-value-convertible" version = "4.2.0-dev.8" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5218,7 +5258,7 @@ version = "4.2.0-dev.8" dependencies = [ "bincode", "grovedb-version", - "thiserror 2.0.18", + "thiserror 2.0.20", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] @@ -5228,7 +5268,7 @@ version = "4.2.0-dev.8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5253,7 +5293,7 @@ dependencies = [ "key-wallet-manager", "log", "platform-encryption", - "rand 0.8.6", + "rand 0.8.8", "rayon", "rs-sdk-trusted-context-provider", "rusqlite", @@ -5377,11 +5417,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -5397,15 +5437,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -5425,9 +5465,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -5491,7 +5531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5510,14 +5550,14 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -5534,7 +5574,7 @@ dependencies = [ "memchr", "parking_lot", "protobuf", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -5545,9 +5585,9 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.13.0", + "bitflags 2.13.1", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift 0.4.0", "regex-syntax", @@ -5582,8 +5622,8 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.4.1", - "itertools 0.10.5", + "heck 0.5.0", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -5593,7 +5633,7 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.117", + "syn 2.0.119", "tempfile", ] @@ -5604,10 +5644,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5617,10 +5657,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5661,51 +5701,31 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "pulldown-cmark" version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "memchr", "unicase", ] [[package]] name = "pulldown-cmark-to-cmark" -version = "22.0.0" +version = "22.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" dependencies = [ "pulldown-cmark", ] [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quanta" @@ -5730,11 +5750,11 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick_cache" -version = "0.6.23" +version = "0.6.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3db184a8b66cfe87f0263a1de147a6b554c864d1767c6f7fa4eb0e5497b565" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" dependencies = [ - "ahash 0.8.12", + "ahash", "equivalent", "hashbrown 0.16.1", "parking_lot", @@ -5742,19 +5762,19 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "rustls", - "socket2 0.6.4", - "thiserror 2.0.18", + "socket2 0.6.5", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -5762,21 +5782,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -5784,23 +5805,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.6.5", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -5825,9 +5846,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -5836,9 +5857,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -5846,12 +5867,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.2", + "chacha20 0.10.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -5899,6 +5920,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.3.0" @@ -5928,9 +5958,9 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.4.1" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ "rustversion", ] @@ -5941,7 +5971,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -5979,7 +6009,7 @@ dependencies = [ "pasta_curves", "rand_core 0.6.4", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "zeroize", ] @@ -5989,27 +6019,27 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -6034,7 +6064,7 @@ dependencies = [ "regex", "rusqlite", "siphasher", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", "walkdir", @@ -6050,14 +6080,14 @@ dependencies = [ "quote", "refinery-core", "regex", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -6067,9 +6097,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6094,15 +6124,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reopen" version = "1.0.3" @@ -6241,35 +6262,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rmp" version = "0.8.15" @@ -6343,7 +6335,7 @@ dependencies = [ "sha2", "tempfile", "test-case", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-tungstenite", @@ -6373,11 +6365,11 @@ dependencies = [ "http", "http-serde", "lru", - "rand 0.8.6", + "rand 0.8.8", "serde", "serde_json", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tonic-web-wasm-client", "tracing", @@ -6406,7 +6398,7 @@ dependencies = [ "dpp", "hex", "platform-version", - "rand 0.8.6", + "rand 0.8.8", "rs-dapi-client", "rs-sdk-trusted-context-provider", "serde", @@ -6444,7 +6436,7 @@ dependencies = [ "serde", "serde_json", "simple-signer", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-metrics", "tracing", @@ -6465,7 +6457,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "url", @@ -6515,17 +6507,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "rtoolbox" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6534,7 +6526,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -6545,16 +6537,16 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.42.1" +version = "1.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", - "rand 0.8.6", - "rkyv", + "rand 0.8.8", + "rand 0.9.5", "serde", "serde_json", "wasm-bindgen", @@ -6567,7 +6559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6578,9 +6570,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -6597,7 +6589,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6610,7 +6602,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6619,9 +6611,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -6647,9 +6639,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -6684,9 +6676,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -6696,9 +6688,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -6777,9 +6769,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -6810,12 +6802,6 @@ version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "sec1" version = "0.7.3" @@ -6837,7 +6823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes", - "rand 0.8.6", + "rand 0.8.8", "secp256k1-sys", "serde", ] @@ -6857,7 +6843,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -6882,9 +6868,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -6942,31 +6928,31 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -6987,13 +6973,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -7044,21 +7030,22 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", - "serde_with_macros 3.21.0", + "serde_with_macros 3.22.0", "time", ] @@ -7071,19 +7058,19 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7118,14 +7105,14 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -7168,7 +7155,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "359e552886ae54d1642091645980d83f7db465fd9b5b0248e3680713c1773388" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "either", "incrementalmerkletree", "tracing", @@ -7207,15 +7194,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -7271,9 +7258,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "socket2" @@ -7287,9 +7274,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -7297,18 +7284,18 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spki" @@ -7361,7 +7348,7 @@ checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" dependencies = [ "hashbrown 0.16.1", "rustversion", - "spin 0.10.0", + "spin 0.10.1", ] [[package]] @@ -7373,7 +7360,7 @@ dependencies = [ "drive", "hex", "platform-version", - "rand 0.8.6", + "rand 0.8.8", "simple-signer", "tokio", "tracing", @@ -7413,7 +7400,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7425,7 +7412,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7462,9 +7449,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -7488,7 +7486,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7497,7 +7495,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -7531,7 +7529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -7549,7 +7547,7 @@ dependencies = [ "semver", "serde_json", "tenderdash-proto", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -7572,7 +7570,7 @@ dependencies = [ "serde", "subtle-encoding", "tenderdash-proto-compiler", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tonic 0.14.6", "tonic-prost", @@ -7617,7 +7615,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7628,7 +7626,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "test-case-core", ] @@ -7643,11 +7641,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -7658,25 +7656,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -7692,9 +7690,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.49" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -7712,9 +7710,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.29" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -7722,9 +7720,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -7742,9 +7740,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -7762,14 +7760,14 @@ dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -7777,7 +7775,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.5", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -7785,20 +7783,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] name = "tokio-metrics" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9e81d53caf955549b1dec7af4ac2149e94cc25ed97b4a545151140281e2f528" +checksum = "01f967a7d11cf59ebeb361546fcc207763de7a885db18176e7042dfc28665f1c" dependencies = [ "futures-util", "pin-project-lite", @@ -7818,9 +7816,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -7828,9 +7826,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -7865,14 +7863,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -7895,7 +7894,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", @@ -7937,7 +7936,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -7948,7 +7947,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -7958,23 +7957,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -7985,9 +7984,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tonic" @@ -8039,7 +8038,7 @@ dependencies = [ "percent-encoding", "pin-project", "rustls-native-certs", - "socket2 0.6.4", + "socket2 0.6.5", "sync_wrapper", "tokio", "tokio-rustls", @@ -8060,7 +8059,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8085,7 +8084,7 @@ dependencies = [ "prost-build", "prost-types 0.14.4", "quote", - "syn 2.0.117", + "syn 2.0.119", "tempfile", "tonic-build", ] @@ -8106,7 +8105,7 @@ dependencies = [ "httparse", "js-sys", "pin-project", - "thiserror 2.0.18", + "thiserror 2.0.20", "tonic 0.14.6", "tower-service", "wasm-bindgen", @@ -8126,7 +8125,7 @@ dependencies = [ "indexmap 1.9.3", "pin-project", "pin-project-lite", - "rand 0.8.6", + "rand 0.8.8", "slab", "tokio", "tokio-util", @@ -8143,7 +8142,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.0", + "indexmap 2.14.2", "pin-project-lite", "slab", "sync_wrapper", @@ -8160,7 +8159,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-core", "futures-util", @@ -8214,7 +8213,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -8227,7 +8226,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8300,7 +8299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8339,7 +8338,7 @@ dependencies = [ "httparse", "log", "native-tls", - "rand 0.8.6", + "rand 0.8.8", "sha1", "thiserror 1.0.69", "url", @@ -8439,11 +8438,11 @@ checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "log", "percent-encoding", @@ -8456,11 +8455,11 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "http", "httparse", "log", @@ -8504,13 +8503,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", - "rand 0.10.1", + "rand 0.10.2", "wasm-bindgen", ] @@ -8563,7 +8562,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8574,7 +8573,7 @@ dependencies = [ "crypto-bigint", "elliptic-curve", "elliptic-curve-tools", - "generic-array 1.4.3", + "generic-array 1.4.5", "hex", "num", "rand_core 0.6.4", @@ -8610,7 +8609,7 @@ dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -8634,16 +8633,7 @@ version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -8694,7 +8684,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -8737,7 +8727,7 @@ checksum = "f579cdd0123ac74b94e1a4a72bd963cf30ebac343f2df347da0b8df24cdebed2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8763,7 +8753,7 @@ dependencies = [ "serde", "serde-wasm-bindgen 0.5.0 (git+https://github.com/QuantumExplorer/serde-wasm-bindgen?branch=feat%2Fnot_human_readable)", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "wasm-bindgen", "wasm-bindgen-futures", "wasm-logger", @@ -8801,7 +8791,7 @@ dependencies = [ "dpp", "drive", "hex", - "indexmap 2.14.0", + "indexmap 2.14.2", "js-sys", "serde", "serde-wasm-bindgen 0.6.5", @@ -8811,16 +8801,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - [[package]] name = "wasm-logger" version = "0.2.0" @@ -8832,18 +8812,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-sdk" version = "4.2.0-dev.8" @@ -8863,14 +8831,14 @@ dependencies = [ "js-sys", "once_cell", "platform-value", - "rand 0.8.6", + "rand 0.8.8", "rs-dapi-client", "rs-sdk-trusted-context-provider", "serde", "serde-wasm-bindgen 0.5.0 (git+https://github.com/dashpay/serde-wasm-bindgen?branch=fix%2Fuint8array-to-bytes)", "serde_json", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "tracing-subscriber", @@ -8895,18 +8863,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" version = "0.3.85" @@ -8929,18 +8885,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -9015,7 +8971,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -9026,7 +8982,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -9254,107 +9210,19 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "withdrawals-contract" version = "4.2.0-dev.8" @@ -9365,14 +9233,14 @@ dependencies = [ "serde", "serde_json", "serde_repr", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -9385,9 +9253,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" [[package]] name = "yansi" @@ -9414,7 +9282,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -9441,22 +9309,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -9476,7 +9344,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -9498,7 +9366,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -9515,7 +9383,7 @@ dependencies = [ "num-traits", "once_cell", "parking_lot", - "rand 0.8.6", + "rand 0.8.8", "regex", "scc", "thiserror 1.0.69", @@ -9526,9 +9394,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -9537,9 +9405,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -9548,13 +9416,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -9570,7 +9438,7 @@ dependencies = [ "flate2", "getrandom 0.3.4", "hmac", - "indexmap 2.14.0", + "indexmap 2.14.2", "lzma-rust2", "memchr", "pbkdf2", @@ -9587,7 +9455,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "crc32fast", "flate2", - "indexmap 2.14.0", + "indexmap 2.14.2", "memchr", "typed-path", "zopfli", @@ -9618,15 +9486,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" @@ -9642,9 +9510,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", @@ -9652,9 +9520,9 @@ dependencies = [ [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-jpeg" diff --git a/Cargo.toml b/Cargo.toml index 74e08d20530..1fa15c41ee7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } tokio-metrics = "0.5" diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json new file mode 100644 index 00000000000..e973d2ec544 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json @@ -0,0 +1,4132 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "1cad63f34d38c2a9c8b5bb328235ac55", + "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, `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": "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, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `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": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "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, `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": "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, 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" + } + ], + "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`)" + } + ], + "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, `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": "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, '1cad63f34d38c2a9c8b5bb328235ac55')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json new file mode 100644 index 00000000000..dfe8467a953 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/12.json @@ -0,0 +1,4139 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "d12a03ec9c3527a1a1a6120cdcd31e47", + "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, `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": "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, `isGloballySwept` INTEGER NOT NULL DEFAULT 0, 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 + }, + { + "fieldPath": "isGloballySwept", + "columnName": "isGloballySwept", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `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": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "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, `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": "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, 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" + } + ], + "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`)" + } + ], + "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, `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": "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, 'd12a03ec9c3527a1a1a6120cdcd31e47')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json new file mode 100644 index 00000000000..218055f2930 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/13.json @@ -0,0 +1,4149 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "263002ef596467980280337c18e9c337", + "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, `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": "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, `isGloballySwept` INTEGER NOT NULL DEFAULT 0, 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 + }, + { + "fieldPath": "isGloballySwept", + "columnName": "isGloballySwept", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `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": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "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, `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": "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`)" + } + ], + "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, `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": "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, '263002ef596467980280337c18e9c337')" + ] + } +} \ 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 e6ce11bee92..e071928623f 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 @@ -394,13 +394,145 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v10 → v11 adds `txos.supersededByTxid` (nullable) and + * `pending_inputs.isSweptTombstone` (defaulted `false`) — both + * additive. Pre-existing rows in each table must survive and read back + * with the new columns at their defaults. + */ + @Test + fun migrate10To11AddsSweepClaimDurabilityColumns() { + val legacy = helper.createDatabase(dbName, 10) + legacy.execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)", + ) + legacy.execSQL( + "INSERT INTO transactions (txid, transactionData, context, blockHeight, " + + "blockTimestamp, blockPosition, hasBlockPosition, direction, " + + "transactionType, transactionTypeKind, netAmount, label, firstSeen, " + + "createdAt, lastUpdated) " + + "VALUES (x'02', x'00', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0)", + ) + legacy.execSQL( + "INSERT INTO txos (outpoint, vout, amount, address, scriptPubKey, height, " + + "isCoinbase, isConfirmed, isInstantLocked, isLocked, isSpent, createdAt, " + + "lastUpdated, walletId, txid) " + + "VALUES (x'0201', 1, 1000, 'y', x'00', 0, 0, 0, 0, 0, 0, 0, 0, x'01', x'02')", + ) + legacy.execSQL( + "INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, walletId, " + + "createdAt) VALUES (x'0301', 0, x'02', x'01', 0)", + ) + legacy.close() + + val db = helper.runMigrationsAndValidate(dbName, 11, true, DashDatabase.MIGRATION_10_11) + db.query("SELECT supersededByTxid FROM txos WHERE outpoint = x'0201'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue(c.isNull(0)) + } + db.query("SELECT isSweptTombstone FROM pending_inputs WHERE outpoint = x'0301'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + db.close() + } + + /** + * v10 → v11 adds `transactions.isGloballySwept` (defaulted `false`) — + * additive. Pre-existing rows must survive and read back not swept, and + * the flag must accept an explicit `true` on write, mirroring + * `migrate10To11AddsSweepClaimDurabilityColumns` above for the sibling + * v11 columns. + */ + @Test + fun migrate11To12AddsGlobalSweptFlag() { + val legacy = helper.createDatabase(dbName, 11) + legacy.execSQL( + "INSERT INTO transactions (txid, transactionData, context, blockHeight, " + + "blockTimestamp, blockPosition, hasBlockPosition, direction, " + + "transactionType, transactionTypeKind, netAmount, label, firstSeen, " + + "createdAt, lastUpdated) " + + "VALUES (x'02', x'00', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0)", + ) + legacy.close() + + val db = helper.runMigrationsAndValidate(dbName, 12, true, DashDatabase.MIGRATION_11_12) + db.query("SELECT isGloballySwept FROM transactions WHERE txid = x'02'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + db.execSQL( + "INSERT INTO transactions (txid, transactionData, context, blockHeight, " + + "blockTimestamp, blockPosition, hasBlockPosition, direction, " + + "transactionType, transactionTypeKind, netAmount, label, firstSeen, " + + "createdAt, lastUpdated, isGloballySwept) " + + "VALUES (x'03', x'00', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0, 1)", + ) + db.query("SELECT isGloballySwept FROM transactions WHERE txid = x'03'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, c.getInt(0)) + } + db.close() + } + + /** + * v12 → v13 adds `pending_inputs.winnerMinedHeight` and + * `wallets.lastAppliedChainLockHeight` (both nullable, no default) — + * additive. Pre-existing rows must survive and read back NULL + * (an unstamped tombstone is never collected, and no chainlock height + * means no finality boundary), and both columns must accept an + * explicit value on write. + */ + @Test + fun migrate12To13AddsWinnerHeightAndChainLockHeight() { + val legacy = helper.createDatabase(dbName, 12) + legacy.execSQL( + "INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, " + + "walletId, createdAt, isSweptTombstone) " + + "VALUES (x'04', 0, x'05', x'06', 0, 1)", + ) + legacy.execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'06', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)", + ) + legacy.close() + + val db = helper.runMigrationsAndValidate(dbName, 13, true, DashDatabase.MIGRATION_12_13) + db.query("SELECT winnerMinedHeight FROM pending_inputs WHERE outpoint = x'04'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue("pre-migration tombstones read back unstamped", c.isNull(0)) + } + db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'06'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue("pre-migration wallets have no chainlock height on record", c.isNull(0)) + } + db.execSQL( + "INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, " + + "walletId, createdAt, isSweptTombstone, winnerMinedHeight) " + + "VALUES (x'07', 0, x'05', x'06', 0, 1, 1234)", + ) + db.query("SELECT winnerMinedHeight FROM pending_inputs WHERE outpoint = x'07'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1234, c.getInt(0)) + } + db.execSQL("UPDATE wallets SET lastAppliedChainLockHeight = 4321 WHERE walletId = x'06'") + db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'06'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(4321, c.getInt(0)) + } + 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, - 10, + 13, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -408,16 +540,19 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_7_8, DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, + DashDatabase.MIGRATION_10_11, + DashDatabase.MIGRATION_11_12, + DashDatabase.MIGRATION_12_13, ).close() } - /** The full chain from v1 must also land on a valid v10 schema. */ + /** The full chain from v1 must also land on a valid v13 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 10, + 13, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -428,6 +563,9 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_7_8, DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, + DashDatabase.MIGRATION_10_11, + DashDatabase.MIGRATION_11_12, + DashDatabase.MIGRATION_12_13, ).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 65c25e423d0..f23b4738912 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 @@ -60,6 +60,19 @@ abstract class NativePersistenceBridge { open fun persistenceCapabilitiesBits(): Long = 0L + companion object { + /** + * `PersistenceCapabilities::CORE_SWEEP_REMOVAL` (bit 11, `0x800`). + * Declared here — on the class whose + * [onWalletChangesetTransactionsSwept] default consults it — so the + * fail-closed guard and the declaration a subclass makes through + * [persistenceCapabilitiesBits] can never drift apart. + * `PlatformWalletPersistenceHandler`'s capability constants alias + * this value. + */ + const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x800 + } + // ── Transactional bracketing ────────────────────────────────────── /** `on_changeset_begin_fn` — descriptor `([B)I`. */ @@ -294,6 +307,109 @@ abstract class NativePersistenceBridge { /** Close the current account bucket. Descriptor `([BI)I`. */ open fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0 + /** + * Transactions the wallet removed in one sweep batch, as raw 32-byte + * txids, each paired by index with the transaction that settled its + * inputs, plus the outpoints this batch actually freed. Invoked once + * PER BATCH, in the round's emission order, after the per-account + * decomposition and only when the round swept something. The order is + * load-bearing, not cosmetic: batches are non-commutative — each + * release is true only of the wallet its own sweep saw, and a later + * batch can keep spent a coin an earlier one freed — so an + * implementation must apply every call's holds before its releases and + * must never fold calls together or reorder them. + * Descriptor `([B[[B[[B[[B[[BI)I`. + * + * [winnerMinedHeight] is the winner's own mined block height for a + * block-context sweep, or -1 for an InstantSend-locked winner not yet + * mined (the sentinel is unambiguous — block heights are + * non-negative — and the handler maps it back to null). It keys the + * lifetime of the durable claim every non-released input retains: a + * stamped hold is collectible once the chainlock finality boundary + * reaches the stamp, while the null case leaves the SAME hold + * UNSTAMPED — an IS-locked winner has no mining deadline, so no + * boundary can prove the held input's funding delivered-or-never — and + * no collector may ever remove an unstamped hold: it resolves only + * through proof, when the funding TXO materializes it, a later + * block-context sweep re-stamps it, or a release deletes it. An + * implementation that drops the hold instead (either by skipping it + * for a -1 winner or by aging it out) deletes the only cross-restart + * carrier of a consumed coin's spend claim and later restores that + * coin as spendable. + * + * Each removed transaction was a recorded spend that its winner beat to + * one of its inputs, so it can never confirm. Every other slot on this + * bus is additive; this is the only removal, and an implementation that + * ignores it keeps dead rows that are handed back at the next load and + * re-create a balance the wallet has already corrected. + * + * [releasedOutpoints] holds 36-byte keys (raw txid followed by a + * little-endian vout) and is wallet-scoped, not attributed per removal: + * an implementation holds every input of every row it deletes, so it + * only needs to know which of them came free. Everything else it holds + * was taken by the transaction that won those inputs and must stay + * spent. The set cannot be inferred from [supersededBy] — that + * transaction may pay entirely to outside addresses and never be + * reported here at all. + * + * [claimedInputs] holds the same 36-byte keys: inputs the removed + * transactions claimed, as far as the wallet's own events prove it, + * for an implementation that never stored the removed transaction. + * "Holds every input of every row it deletes" fails for a transaction + * swept before its own detection reached persistence — no row was + * written, so there is nothing to walk — and these keys are then the + * only evidence that the winner consumed this wallet's coin. Settle + * each exactly like an input of a row this call deletes: free it if + * [releasedOutpoints] names it, otherwise hold it spent under its + * batch's winner (a durable placeholder when the funding TXO has not + * arrived). Usually empty; may repeat inputs of a row that does exist, + * which settle idempotently. + * + * Native delivers these through the persistence extension's + * size-negotiated sweep callback (not the wallet-changeset struct, whose + * bare-pointer ABI cannot version itself), immediately after the + * changeset's own slots in the same round — and unconditionally: the + * trampoline is wired for every subclass, so "slot present" proves + * nothing about whether removals are actually applied. What Rust trusts + * is [persistenceCapabilitiesBits] carrying + * [CAPABILITY_CORE_SWEEP_REMOVAL]; a subclass overriding this must add + * that bit, and the default body below is what encodes the other half + * of that contract structurally. A subclass that declares the bit + * WITHOUT overriding has promised removals it silently swallows — and + * because the declaration also stops Rust stripping the watermark, the + * sync height would advance past a removal that never happened, the + * one permanent corruption the capability exists to prevent. The + * default therefore refuses the round in exactly that case (non-zero + * return, so `onChangesetEnd` rolls it back and the watermark cannot + * move). A subclass that declares nothing keeps the benign ignore: + * Rust already strips the watermark before its `store()`, so returning + * success costs nothing and preserves the round's additive slots. + */ + @Suppress("LongParameterList") + open fun onWalletChangesetTransactionsSwept( + walletId: ByteArray, + txids: Array, + supersededBy: Array, + releasedOutpoints: Array, + claimedInputs: Array, + winnerMinedHeight: Int, + ): Int = + if (persistenceCapabilitiesBits() and CAPABILITY_CORE_SWEEP_REMOVAL != 0L) 1 else 0 + + /** + * The round's numeric chainlock height, fired on chainlock-advancing + * persistence rounds after the header slot. Descriptor `([BI)I`. + * + * The bincode chainlock blob on the header call is opaque to Kotlin, + * and this scalar is the half of the swept-tombstone collection + * boundary `min(chainlockHeight, syncedHeight)` an implementation + * cannot otherwise know. Purely additive: a host that ignores it + * simply never collects tombstones, which is the safe direction — + * holding a tombstone forever is junk, collecting one early is a + * wrongly-freed claim. + */ + open fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = 0 + // ── Identities ──────────────────────────────────────────────────── /** 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 13e78e16471..9a7152bb6e5 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 @@ -119,9 +119,45 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * document id, ownership/sale state, counterparty, document timestamps and * marketplace reconciliation watermark. Defaults keep every legacy label an * owned, unlisted row until the first native marketplace sync refreshes it. + * + * Version 11 (sweep claim durability): adds `txos.supersededByTxid` and + * `pending_inputs.isSweptTombstone`. A sweep's winner can beat a loser to an + * input whose funding TXO hasn't landed here yet, and until now the only + * record of that claim was the loser's own `pending_inputs` row, which + * cascades away with the loser it names — leaving the funding TXO's later + * arrival free to re-insert the outpoint as an ordinary unspent UTXO. Both + * columns are additive with defaults, so every pre-migration row reads back + * as an ordinary (non-tombstone, non-superseded) entry. + * + * Version 12 (sweep deletion durability): adds `transactions.isGloballySwept`. + * `commit_batch` calls `store()` once per wallet and each commits + * independently, so a shared loser row could be held back for as long as a + * second wallet's own claim on it was outstanding — which, before this + * column existed, meant the row AND the outputs it created stayed fully live + * (enumerable, funds-bearing) for however long that second wallet's callback + * took to arrive, or forever if it never did. This flag is set in every + * wallet's callback that observes the sweep, not only the one whose delete + * happens to remove the row, so the exclusion from restore/enumeration is + * durable from the first committed callback regardless of what the others + * do. Additive with a default, so every pre-migration row reads back as not + * swept. + * + * Version 13 (bounded tombstone lifetime): adds + * `pending_inputs.winnerMinedHeight` — the sweep winner's own mined block + * height, the stamp the collector compares against the chainlock finality + * boundary `min(chainlockHeight, syncedHeight)` — and + * `wallets.lastAppliedChainLockHeight`, the numeric chainlock height + * delivered by `onWalletChangesetChainLockHeight` that supplies the + * chainlock half of that boundary (the bincode chainlock blob is opaque + * here). A tombstone for a foreign input of a swept incoming payment + * never drains, and before these columns existed it was permanent — junk + * an attacker could grow without limit by double-spending payments at the + * wallet. Both nullable and additive: pre-migration tombstones read back + * unstamped and are never collected (no proof of finality), and a wallet + * with no recorded chainlock height has no boundary at all. */ @Database( - version = 10, + version = 13, exportSchema = true, entities = [ WalletEntity::class, @@ -556,6 +592,53 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v10 → v11: additive sweep-claim-durability columns, both + * defaulted so every existing row reads as "not a tombstone, not + * superseded" (column order = entity field order). + */ + val MIGRATION_10_11: Migration = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `txos` ADD COLUMN `supersededByTxid` BLOB") + db.execSQL( + "ALTER TABLE `pending_inputs` ADD COLUMN `isSweptTombstone` " + + "INTEGER NOT NULL DEFAULT 0", + ) + } + } + + /** + * v11 → v12: adds `transactions.isGloballySwept` (additive, + * defaulted `false`) — see the version-12 class doc above. + */ + val MIGRATION_11_12: Migration = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `transactions` ADD COLUMN `isGloballySwept` " + + "INTEGER NOT NULL DEFAULT 0", + ) + } + } + + /** + * v12 → v13: adds `pending_inputs.winnerMinedHeight` and + * `wallets.lastAppliedChainLockHeight` (both additive, nullable — + * no default needed) — see the version-13 class doc above. + * Pre-migration tombstones read back unstamped and are never + * collected; the chainlock height starts NULL, so no boundary + * exists until `onWalletChangesetChainLockHeight` records one. + */ + val MIGRATION_12_13: Migration = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `pending_inputs` ADD COLUMN `winnerMinedHeight` INTEGER", + ) + db.execSQL( + "ALTER TABLE `wallets` ADD COLUMN `lastAppliedChainLockHeight` INTEGER", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -574,6 +657,9 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, + MIGRATION_10_11, + MIGRATION_11_12, + MIGRATION_12_13, ) .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 fa807e83c00..48688fceccd 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 @@ -135,7 +135,8 @@ class PlatformWalletPersistenceHandler( CAPABILITY_UNSIGNED_TOKEN_STORAGE or CAPABILITY_WALLET_RESTORE or CAPABILITY_DPNS_NAME_STATES or - CAPABILITY_TRACKED_ASSET_LOCKS + CAPABILITY_TRACKED_ASSET_LOCKS or + CAPABILITY_CORE_SWEEP_REMOVAL /** * The single-thread executor created when no [dispatcher] is injected. @@ -704,10 +705,86 @@ class PlatformWalletPersistenceHandler( lastUpdated = now(), ), ) + // Bounded tombstone lifetime, synced-height half (the Room + // mirror of the SQLite store's `collect_finalized_tombstones`): + // once the chainlock finality boundary reaches a swept + // tombstone's winner height, the row has provably never + // drained — a genuine claim's row is deleted by the drain in + // `onWalletChangesetUtxoAdded` when its funding TXO lands — so + // what remains is junk from foreign inputs of swept incoming + // payments, previously permanent and attacker-growable. The + // chainlock half of the boundary is the NUMERIC height + // `onWalletChangesetChainLockHeight` stores on the wallet row; + // the mere presence of chainlock bytes proves nothing about + // WHICH block is final, so until a numeric height is on record + // there is no boundary and nothing collects — mirroring + // upstream's "no-op until a chainlock height has been + // persisted". + if (hasSyncedHeight && syncedHeight > 0) { + collectFinalizedSweptTombstones( + db, walletId, + chainLockHeight = wallet.lastAppliedChainLockHeight, + syncedHeight = syncedHeight, + ) + } } 0 } + override fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = guarded { + stage(walletId) { db -> + // Drop stale post-deletion callbacks (can't resurrect a wallet). + val wallet = db.walletDao().getByWalletId(walletId) ?: return@stage + // Monotonic max — a stale round's chainlock never lowers the + // finality boundary, matching the SQLite store's + // `upsert_sync_state`. + val advanced = maxOf(height, wallet.lastAppliedChainLockHeight ?: Int.MIN_VALUE) + db.walletDao().upsert( + wallet.copy( + lastAppliedChainLockHeight = advanced, + lastUpdated = now(), + ), + ) + // Bounded tombstone lifetime, chainlock half: this call is + // what turns the boundary on at all (no numeric height, no + // collection), so a chainlock-advancing round must collect + // too — otherwise a wallet whose synced height stopped moving + // would hold finalized junk until the next header. + collectFinalizedSweptTombstones( + db, walletId, + chainLockHeight = advanced, + syncedHeight = wallet.syncedHeight, + ) + } + 0 + } + + /** + * Delete this wallet's swept tombstones whose winner's mined height + * the chainlock finality boundary `min(chainlockHeight, syncedHeight)` + * has reached — key-wallet's `prune_finalized_observed_spends` + * condition verbatim, and the same boundary the SQLite store's + * `collect_finalized_tombstones` applies. Both halves must be on + * record: without a numeric chainlock height nothing is provably + * final, and without filter coverage up to the winner's height the + * funding output could still be delivered by the unscanned range. + * Called from both watermark writers — the header (synced height) and + * the chainlock-height slot — since either half advancing can + * complete the boundary. + */ + private suspend fun collectFinalizedSweptTombstones( + db: DashDatabase, + walletId: ByteArray, + chainLockHeight: Int?, + syncedHeight: Int, + ) { + if (chainLockHeight == null || syncedHeight <= 0) return + db.documentDao().collectFinalizedSweptTombstones( + walletId, + boundary = minOf(chainLockHeight, syncedHeight), + ) + } + override fun onWalletChangesetAccountBegin( walletId: ByteArray, accountIndex: Int, @@ -784,6 +861,45 @@ class PlatformWalletPersistenceHandler( ): Int = guarded { stage(walletId) { db -> val existing = db.transactionDao().getByTxid(txid) + // A sweep is upstream's word at the moment it fired, but the + // wallet's sweep state is not monotonic: `CoreChangeSet::merge` + // (rs-platform-wallet) documents the exact reachable sequence — + // an unconfirmed transaction swept by an IS-locked conflict can + // return chainlocked and sweep that conflict in turn, per + // key-wallet's own IS-lock precedence rules. When both events + // land in the same changeset the merge already strips the sweep + // before it gets here. Across separate rounds it can't: the + // earlier sweep is already durable (row tombstoned, possibly + // still physically present because another wallet's claim held + // the delete back — see [onWalletChangesetTransactionsSwept]), + // and this later record is the only signal this callback ever + // sees that the wallet reversed itself. Upstream never re-emits + // a live record for a txid it still considers dead, so a record + // naming an `isGloballySwept` txid is authoritative + // reinstatement, not a stale replay — treat it as upstream's + // newer word and let it win. + // + // No explicit clear is written here: the `@Upsert` below always + // constructs a fresh [TransactionEntity] without naming + // `isGloballySwept`, so it defaults to `false` and Room's + // upsert (a full-row replace on the `txid` primary key) + // overwrites the stored `true` unconditionally. What this does + // and does not restore: `context`/`blockHeight`, involvement, + // and this record's own input reconciliation all rebuild + // normally below since they're driven straight off the FFI + // params. The outputs `onWalletChangesetTransactionsSwept` + // physically deleted are a different story — they come back + // only if this round also carries a fresh + // `onWalletChangesetUtxoAdded` for them, the same way any + // transaction's outputs ordinarily arrive alongside its record. + // The JNI bridge (`persist_changeset_account` in + // rs-unified-sdk-jni) calls this method before any + // `onWalletChangesetUtxoAdded` for the same account, + // specifically so that method's own `isGloballySwept` guard + // already sees this upsert's clear by the time it runs; see the + // comment there. If Rust doesn't re-emit the outputs, they + // cannot be reconstructed here from nothing. + // See TransactionEntity.isGloballySwept. // firstSeen: adopt non-zero from FFI; else keep existing; // else stamp now (never leave a placeholder zero). val resolvedFirstSeen = when { @@ -856,11 +972,29 @@ class PlatformWalletPersistenceHandler( // Found: link the spend. Monotonic — only a confirmed // (in-block) context flips isSpent; a mempool re-emit never // downgrades a flag that is already true (mirrors spendIsInBlock). + // + // The LINK is guarded, not last-writer-wins. `spendingTxid` + // is this store's spend attribution, and the sweep release + // pass trusts it: `holdSpentWithoutSpender` detaches rows + // by it and `releaseByOutpoint` frees only detached rows. + // A network-final spender's link must therefore never be + // stolen by a later conflicting record — upstream prunes a + // chainlocked spender to a bare txid (and after a restart + // holds no history at all), so a loser reusing that coin + // arrives with upstream unable to see the settled claim, + // and its own eventual sweep would name the coin released. + // With the link intact the hold pass never detaches the + // row and the release is refused; with it stolen, the + // provably consumed coin reads unspent after the next + // restart — a guaranteed double spend. The one legitimate + // theft is DIP-10 precedence: a chainlocked arrival may + // take a coin from a spender that was only IS-locked. + val keepExistingLink = keepSettledSpenderLink(db, txo, txid, context) db.txoDao().upsert( txo.copy( isSpent = txo.isSpent || context >= CONTEXT_IN_BLOCK, - spendingTxid = txid, - spendingInputIndex = i, + spendingTxid = if (keepExistingLink) txo.spendingTxid else txid, + spendingInputIndex = if (keepExistingLink) txo.spendingInputIndex else i, lastUpdated = now(), ), ) @@ -901,9 +1035,32 @@ class PlatformWalletPersistenceHandler( ): Int = guarded { stage(walletId) { db -> val outpoint = makeOutpoint(txid, vout) + val parentTx = db.transactionDao().getByTxid(txid) + // A globally-swept parent is a transaction Rust has already + // proven can never confirm — a fresh UTXO entry naming its txid + // would (re-)create exactly the phantom output + // `onWalletChangesetTransactionsSwept` deletes on every callback + // that observes the sweep. Bail rather than attach a new row to + // a transaction still excluded from restoration. + // + // This does not fight [onWalletChangesetTransaction]'s + // reinstatement path — it relies on that method running first. + // The JNI bridge (`persist_changeset_account` in + // rs-unified-sdk-jni) calls `onWalletChangesetTransaction` for + // an account's `transactions` before this method for that same + // account's `utxos_added`, so a reinstating record for this + // txid in this same round has already cleared the tombstone by + // the time this guard reads it; only a UTXO entry with no + // accompanying record this round still finds the flag set. That + // is genuinely a stale/out-of-order signal — Rust does not + // otherwise re-emit a swept loser's own outputs — and staying + // defensive here is correct: there is no record in flight to + // attribute a resurrected output to. See + // TransactionEntity.isGloballySwept. + if (parentTx?.isGloballySwept == true) return@stage // Ensure a parent transaction row exists (stub if missing, so // the TXO FK holds; the real tx upsert overwrites it later). - if (db.transactionDao().getByTxid(txid) == null) { + if (parentTx == null) { db.transactionDao().upsert( TransactionEntity(txid = txid, transactionData = ByteArray(0)), ) @@ -921,7 +1078,22 @@ class PlatformWalletPersistenceHandler( isConfirmed = isConfirmed, isInstantLocked = isInstantLocked, isLocked = isLocked, - isSpent = existing?.isSpent ?: false, + // The wallet is handing this outpoint over as a UTXO, so it + // holds it unspent — authoritative, and the only thing that + // lifts a mark with neither a spender nor a winner behind + // it (a pre-stamp row from before `holdSpentWithoutSpender` + // named its winner; every hold written today is stamped). A + // row whose spend is still on record keeps its flag — the + // pending drain below owns that transition — and so does a + // `supersededByTxid` hold: the winner that consumed this + // coin is known even though its row never materialized + // here, and a re-delivery cannot outrank that verdict — a + // restore-rescan re-finds the funding output precisely + // because it is blind to an unconfirmed winner no block + // carries yet. Only an explicit release + // (`releaseByOutpoint`) frees a stamped coin. + isSpent = existing?.isSpent == true && + (existing.spendingTxid != null || existing.supersededByTxid != null), walletId = walletId, txid = txid, spendingTxid = existing?.spendingTxid, @@ -930,6 +1102,7 @@ class PlatformWalletPersistenceHandler( coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), createdAt = existing?.createdAt ?: java.util.Date(), lastUpdated = now(), + supersededByTxid = existing?.supersededByTxid, ) db.txoDao().upsert(row) // Drain any pending-input rows staged before this funding TXO @@ -942,17 +1115,56 @@ class PlatformWalletPersistenceHandler( // Rust as spendable. val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) if (pending.isNotEmpty()) { - val chosen = pending.maxByOrNull { it.createdAt }!! + // A tombstone outranks every ordinary row regardless of age. + // Newest-wins arbitrates between competing *observations* + // (reorg / double-spend sightings), but a tombstone is not an + // observation — it is the sweep's settled verdict that its + // winner consumed this coin. The two coexist in exactly one + // way: records precede sweeps within a round, so the winner's + // own record can stage an ordinary pending row for this + // outpoint moments before the sweep repoints the loser's row + // — which keeps its original, older `createdAt`. Letting the + // younger ordinary row win there would take the gated branch + // below (`isSpent` false until the winner confirms), never + // stamp `supersededByTxid`, and then delete every row + // including the tombstone — the durable hold evaporates and + // the consumed coin re-enters the restore set. + val chosen = pending.filter { it.isSweptTombstone }.maxByOrNull { it.createdAt } + ?: pending.maxByOrNull { it.createdAt }!! val spending = db.transactionDao().getByTxid(chosen.spendingTxid) - val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK - db.txoDao().upsert( - row.copy( - isSpent = row.isSpent || spentInBlock, - spendingTxid = chosen.spendingTxid, - spendingInputIndex = chosen.inputIndex, - lastUpdated = now(), - ), - ) + if (chosen.isSweptTombstone) { + // `onWalletChangesetTransactionsSwept` repointed this row + // at the sweep's winner because the loser it originally + // recorded is gone. A sweep's winner is already final — + // there is no mempool state to wait out — so `isSpent` + // does not gate on `spending` the way an ordinary pending + // spend does; that lookup only succeeds when the winner + // happens to have its own materialized row, which isn't + // guaranteed (and `spendingTxid`'s FK forbids forcing the + // reference otherwise). `supersededByTxid` is what makes + // the mark durable either way — it is what the recovery + // clear above checks so this coin isn't handed back as + // spendable on a later sync. + db.txoDao().upsert( + row.copy( + isSpent = true, + spendingTxid = spending?.txid ?: row.spendingTxid, + spendingInputIndex = chosen.inputIndex, + supersededByTxid = chosen.spendingTxid, + lastUpdated = now(), + ), + ) + } else { + val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK + db.txoDao().upsert( + row.copy( + isSpent = row.isSpent || spentInBlock, + spendingTxid = chosen.spendingTxid, + spendingInputIndex = chosen.inputIndex, + lastUpdated = now(), + ), + ) + } for (p in pending) db.documentDao().deletePendingInput(p) } } @@ -970,12 +1182,38 @@ class PlatformWalletPersistenceHandler( val txo = db.txoDao().getByOutpoint(outpoint) ?: return@stage // Only mark spent when the spending tx exists in-block (never // flap false on an unresolved spend), mirroring markUtxoSpent. + // A `supersededByTxid` hold is likewise off limits: this emit + // can carry the sweep winner's own IS-locked spend of a coin + // the sweep already proved consumed, and the in-block gate's + // answer would flip the durable hold back into the restore set + // until the winner reaches a block. val spending = db.transactionDao().getByTxid(spendingTxid) val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK + // Same link guard as `onWalletChangesetTransaction`: a + // network-final spender's attribution is what the sweep release + // pass trusts, so it is never stolen by a conflicting later + // spend. Unreachable on this channel today — upstream only + // emits utxos_spent for inputs it classified against a + // still-present funding UTXO, which a settled spend has already + // consumed — but the invariant is cheap to hold here and does + // not depend on that classification detail staying true. + val keepExistingLink = + keepSettledSpenderLink(db, txo, spendingTxid, spending?.context ?: 0) db.txoDao().upsert( txo.copy( - spendingTxid = if (spending != null) spendingTxid else txo.spendingTxid, - isSpent = if (spending != null) spentInBlock else txo.isSpent, + spendingTxid = if (spending != null && !keepExistingLink) { + spendingTxid + } else { + txo.spendingTxid + }, + // When the settled link is kept, the `isSpent` gate is + // not re-answered from the usurper either — the whole + // claim is dropped, not just the link write. + isSpent = if (spending != null && !keepExistingLink) { + spentInBlock || txo.supersededByTxid != null + } else { + txo.isSpent + }, lastUpdated = now(), ), ) @@ -985,6 +1223,433 @@ class PlatformWalletPersistenceHandler( override fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0 + /** + * Delete the mirror of transactions the wallet swept. + * + * Each was a recorded spend that its winner beat to one of its inputs, + * so it can never confirm and Rust has already dropped it. Keeping the + * rows would hand them back at the next load and re-create a balance the + * wallet has already corrected. + * + * `isGloballySwept` is upstream's word as of this callback, not a + * permanent verdict — the wallet's sweep state can itself be swept in + * turn (IS-lock precedence: a chainlocked return beats the IS-locked + * conflict that swept it originally), and [onWalletChangesetTransaction] + * clears this flag when a later record reinstates the txid. See that + * method's doc comment for what reinstatement can and cannot undo. + * + * `commit_batch` calls `store()` once per wallet, and each of those + * commits independently — there is no single transaction spanning every + * wallet a sweep touches. That splits what has to be durable in THIS + * callback from what may wait for a later one. The TXOs a loser created + * are phantom money for every wallet, not just whichever one's callback + * happens to run, and once Rust has proven a row dead no restore/ + * enumeration query may serve it to anyone again — waiting for the last + * wallet's callback to confirm that would leave it acknowledged-but- + * resurrectable for however long the others take, or forever if one of + * them is rejected or never arrives. So [TxoDao.deleteOwnOutputs] and + * [TransactionDao.markGloballySwept] run in EVERY callback that reaches + * this function, idempotently, before anything wallet-scoped below. + * Physically removing the `transactions` row itself is different: that + * is safe to defer, because `isGloballySwept` already makes the row + * inert the moment the first callback sets it — see [hasOtherWalletClaim] + * for why the row is still worth reclaiming once nothing points at it, + * now purely as housekeeping. + * + * The TXOs the transaction created go with it (`txos.txid` cascades, + * once the row itself is deleted — [TxoDao.deleteOwnOutputs] above does + * not wait for that). The ones it *spent* split in two, and + * [releasedOutpoints] is the + * authority on which is which: an outpoint named there came free, and + * every other input the loser claimed was taken by the transaction that + * beat it and is gone for good. + * + * That split cannot be worked out here. A swept loser is always + * unconfirmed upstream, and this store flips `isSpent` only for a + * spender that reached a block, so the loser holds its inputs by link + * alone at `isSpent = 0`; deleting the row nils the link and every one + * of those coins would return to the restore set, the winner's + * included. Nor can the winner's own row settle it — it may pay only to + * outside addresses and never be recorded here, and even a relevant one + * is not guaranteed to land in the same round as the sweep. + * + * A held input can also have no `TxoEntity` at all yet — the loser was + * persisted before its own funding TXO was, so `onWalletChangesetTransaction` + * parked the claim as a `pending_inputs` row instead (see + * `PendingInputEntity`). That row's FK cascades on [txids]' own delete + * below just like the TXOs do, so left alone the claim would vanish with + * the loser, and the funding TXO's own later `onWalletChangesetUtxoAdded` + * — even after a restart — would have nothing to tell it the coin isn't + * really free. The staged rows are therefore fetched by their loser's + * FK ([DocumentDao.pendingInputsStagedBy]) and partitioned in memory: + * a released one is deleted outright, a held one is detached from its + * doomed loser and repointed at the corresponding [supersededBy] entry, + * flagged `isSweptTombstone` so the drain in + * `onWalletChangesetUtxoAdded` knows to keep the coin spent — durably, + * via `TxoEntity.supersededByTxid` — once the funding TXO materializes. + * + * The tombstone is created for EVERY sweep context; only the stamp + * differs. A BLOCK-CONTEXT sweep ([winnerMinedHeight] non-null) + * stamps the winner's own mined height — the projection of + * key-wallet's `observed_spent_outpoints` — and the collector evicts + * the row once the chainlock finality boundary reaches it. An + * IS-locked, unmined winner leaves the SAME tombstone UNSTAMPED + * (null), which the collector never touches. The in-memory model an + * unstamped tombstone mirrors is the account's `spent_outpoints`: + * upstream's `drop_conflicted_transactions` deletes the loser and + * RETAINS the winner's shared inputs there — under DIP-10 the IS + * lock alone settles them — but that set is rebuilt from live + * records on load, and after the sweep neither the deleted loser nor + * a (possibly wallet-irrelevant) winner leaves a record to rebuild + * it from. The tombstone is the hold's only durable carrier; + * dropping it lets a post-restart funding delivery credit a coin the + * network has provably consumed. + * + * Nothing may collect an unstamped tombstone: an IS-locked winner + * has no mining deadline (and the funding tx of an input it spends + * may itself be IS-locked and unmined), so no watermark proves the + * funding delivered-or-never. It resolves only through proof — the + * funding TXO drains it (a wallet-owned claim always eventually + * delivers via BIP158), a later block-context sweep re-stamps it + * into the collectible set, or a release deletes it. The permanent + * residue is foreign inputs of IS-context sweeps (a swept incoming + * payment's sender-owned inputs; ownership cannot gate it — nothing + * can prove an input foreign, dashpay/rust-dashcore#968), bounded by + * attack cost rather than collection: masternodes lock first-seen, + * so every such row needs a conflicting payment delivered straight + * to this wallet while withheld from the network, plus a fee-paying + * IS-locked double-spend. Materialized `TxoEntity` rows still get + * spend-marked either way — they carry real funding data, so + * holding them costs nothing an attacker controls. + * + * A tombstoned row can itself need to move again: [supersededBy] is a + * winner in this round, but nothing stops it from losing a later round + * to a further winner while [supersededBy]'s own funding TXO is still + * unresolved. The staged-row fetch can't see that earlier tombstone — + * it already detached from the relationship that query matches on — so + * [DocumentDao.sweptTombstonesTargeting] looks it up the only other + * way it is still findable, by the scalar `spendingTxid` it was + * repointed to, and the same in-memory partition carries it the rest + * of the chain: deleted if this round finally frees its outpoint, + * repointed at the new winner if not. + * + * All updates run before the delete: the foreign key nulls `spendingTxid` + * (or, for a pending row already detached above, does nothing) on delete, + * and after that nothing finds those rows. + * + * Transaction rows are keyed by txid alone, shared across wallets by + * design — the same loser can spend coins from more than one wallet at + * once — but [releasedOutpoints] is not shared: upstream computes it per + * wallet (`per_wallet_released_outpoints`), so this call's set says + * nothing about an input a *different* wallet's coin claims on the same + * row. Every DAO call above therefore carries [walletId] and only + * touches that wallet's own rows (own `TxoEntity`s via `TxoDao`'s + * `walletId` column, own `PendingInputEntity`s via the same column on + * that table). Deleting the row itself is different: nothing below + * depends on it for correctness anymore, since the global writes above + * already made the row inert in every callback that reaches them. It is + * deleted once [hasOtherWalletClaim] finds nothing left pointing at it — + * whichever wallet's callback is the last one to run performs the + * delete, so processing order stops mattering — but this is reclaiming + * the now-inert row's storage, not finishing the sweep. A wallet whose + * callback never arrives just leaves the row behind with every other + * wallet's inputs already correctly decided: a leaked dead row, not a + * wrongly-spent coin or a resurrectable one, and a re-emitted sweep + * cleans it up. + */ + override fun onWalletChangesetTransactionsSwept( + walletId: ByteArray, + txids: Array, + supersededBy: Array, + releasedOutpoints: Array, + claimedInputs: Array, + winnerMinedHeight: Int, + ): Int = guarded { + stage(walletId) { db -> + if (db.walletDao().getByWalletId(walletId) == null) return@stage + // The winner's finality context: its own mined block height + // for a block-context sweep, null (JNI sentinel -1) for an + // InstantSend-locked winner not yet mined. This is the whole + // lifetime rule of any tombstone this round creates or + // re-points — the collector compares it against the chainlock + // finality boundary — and the null case leaves the tombstone + // unstamped, which the collector never touches. + val winnerHeight = winnerMinedHeight.takeIf { it >= 0 } + // Hold every input first, then free the ones upstream named: the + // released set spans the whole round's removals, so it is + // applied once rather than per transaction. + // + // The order is load-bearing, not cosmetic. Holding detaches the + // rows this round's removals still claim, and the release only + // touches detached rows — so a coin some later transaction in the + // same round already re-claimed keeps that claim instead of being + // freed out from under it. + val released = releasedOutpoints.toList() + // Hoisted: the set is invariant across the losers, and a sweep + // can carry many of them — the mempool alone tracks up to a + // thousand conflicts. Rebuilding it per loser would hex-encode + // and hash the whole set L times inside the open Room + // transaction, turning a linear payload into L×R work on the + // single persistence executor. + val releasedKeys = released.mapTo(HashSet()) { it.toHex() } + // The funding txids this batch itself removes. A pending claim + // whose outpoint is funded by a co-swept loser is a claim on a + // dead parent's output — nobody's coin, not something the + // winner took: upstream's descendant closure always sweeps + // parent and child together, and its release computation + // excludes exactly these outpoints, so the claim is neither + // released nor legitimate to hold. Tombstoning it to the + // winner would wedge the parent's chainlocked reinstatement + // forever: the re-delivered funding output drains into the + // tombstone-outranks pick, `supersededByTxid` pins the hold, + // and the recovery clear refuses stamped rows. Deleted + // outright instead — the mobile mirror of the SQLite + // co-swept DELETE. + val sweptTxidKeys = txids.mapTo(HashSet()) { it.toHex() } + for (i in txids.indices) { + // Global first, unconditionally, in every callback that + // reaches this loop — not gated on walletId and not waiting + // for whichever wallet ends up performing the row delete + // below. Both writes are idempotent, so a wallet reprocessing + // an already-flagged sweep (a retry after a crash) just + // re-applies the same state. + db.txoDao().deleteOwnOutputs(txids[i]) + db.transactionDao().markGloballySwept(txids[i]) + + db.txoDao().holdSpentWithoutSpender(txids[i], walletId, supersededBy[i]) + + // The released set is partitioned in memory rather than + // bound into SQL: its size follows the input count of a + // transaction a remote sender picks, and one bind variable + // per outpoint can cross API 29's 999-variable ceiling — + // which would throw, fail the atomic round, and freeze the + // watermark on a loser that re-swept into the same failure + // on every restart. + val staged = db.documentDao().pendingInputsStagedBy(txids[i], walletId) + val (goneStaged, heldStaged) = staged.partition { + releasedKeys.contains(it.outpoint.toHex()) || + sweptTxidKeys.contains(it.outpoint.copyOfRange(0, 32).toHex()) + } + // Released staged rows go now rather than riding the + // eventual cascade. Left attached they count as this + // wallet's claim in `hasOtherWalletClaim` below, so two + // wallets each holding one released input for a shared + // loser deadlock: each sees the other's row and declines the + // delete, and replaying either callback reaches the same + // stalemate. The global marker keeps the dead transaction + // from contributing funds either way, but the row and both + // pending entries would otherwise be stored forever. + // Claims on a co-swept loser's own outputs go with them — + // see `sweptTxidKeys` above. + if (goneStaged.isNotEmpty()) { + db.documentDao().deletePendingInputs(goneStaged) + } + if (heldStaged.isNotEmpty()) { + // Held in every winner context — `CORE_SWEEP_REMOVAL` + // requires each non-released input to keep a durable + // spend claim before its funding TXO materializes. A + // block-context winner stamps its mined height; an + // IS-locked, unmined winner leaves the stamp null and + // the collector never touches the row — see the doc + // comment above for what resolves an unstamped hold. + db.documentDao().updatePendingInputs( + heldStaged.map { + it.copy( + spendingTransactionTxid = null, + spendingTxid = supersededBy[i], + isSweptTombstone = true, + winnerMinedHeight = winnerHeight, + ) + }, + ) + } + // A pending input an EARLIER sweep already tombstoned to + // txids[i] (that txid was itself a sweep's winner, and is + // now being swept in turn) detached from the relationship + // the staged-row fetch above matches on, so it has to be + // found by the scalar `spendingTxid` it was repointed to + // and carried forward separately — see + // [DocumentDao.sweptTombstonesTargeting]. + val prior = db.documentDao().sweptTombstonesTargeting(txids[i], walletId) + val (gonePrior, stillHeld) = prior.partition { + releasedKeys.contains(it.outpoint.toHex()) || + sweptTxidKeys.contains(it.outpoint.copyOfRange(0, 32).toHex()) + } + if (gonePrior.isNotEmpty()) { + db.documentDao().deletePendingInputs(gonePrior) + } + if (stillHeld.isNotEmpty()) { + // Re-pointed to a new block-context winner ⇒ + // re-stamped with THAT winner's mined height: the + // claim now belongs to a spend anchored at a later + // block, and its collection horizon moves with it. An + // IS-locked winner (null height) re-points the claim + // but keeps the existing stamp — upstream's + // observed-spend entry is never retracted by an + // unconfirmed conflict, and collection at the old + // height stays sound: the funding output of a spent + // outpoint is mined at or below the height of ANY + // block-context spender of it, so the boundary + // passing that height still proves the funding was + // delivered or never will be. + db.documentDao().updatePendingInputs( + stillHeld.map { + it.copy( + spendingTxid = supersededBy[i], + winnerMinedHeight = winnerHeight ?: it.winnerMinedHeight, + ) + }, + ) + } + } + // A winner recorded before this sweep can already own the input + // relationship, leaving the persisted loser with no link for the + // per-loser pass above to follow. The sweep still settles every + // coin attributed to its winner, independently of whether the + // batch needed to carry any claimed inputs. + val batchWinner = supersededBy.firstOrNull() + if (batchWinner != null) { + db.txoDao().holdInputsLinkedToWinner(batchWinner, walletId) + } + // Inputs the batch vouches for on behalf of a loser this store + // never held: it was swept before its own detection reached + // persistence, so the loop above found no staged rows to hold + // and `holdSpentWithoutSpender` no link to follow. The batch + // carries the detection's own input details instead, and each + // is settled exactly like a deleted row's input — released ones + // are left to the release pass below, a co-swept parent's + // output is nobody's coin, and everything else is held spent + // under the winner: on the coin's row when it is detached or + // already linked to that winner, otherwise as the same tombstone + // the staged-row path writes when no funding row exists, so the + // funding TXO's arrival (even after a restart) drains into a held + // coin rather than a fresh unspent one. Repeats of an input the + // loop above already settled land on the same values. + // The batch's winner is the same for every loser in it + // (`supersededBy` is one value repeated per loser), so any slot + // names it. A batch that vouches for inputs but names no + // winner cannot be settled; failing the round keeps Rust from + // clearing a sweep whose hold never landed, the same contract + // as every other refusal here. + if (batchWinner == null && claimedInputs.isNotEmpty()) { + throw IllegalStateException( + "sweep batch vouches for ${claimedInputs.size} input(s) but names no winner", + ) + } + for (outpoint in claimedInputs) { + if (releasedKeys.contains(outpoint.toHex()) || + sweptTxidKeys.contains(outpoint.copyOfRange(0, 32).toHex()) + ) { + continue + } + val winner = batchWinner ?: continue + val held = db.txoDao().holdClaimedInput(outpoint, walletId, winner) + if (held > 0 || db.txoDao().getByOutpoint(outpoint) != null) continue + // Only an earlier tombstone for this winner (and this + // wallet) counts as "already written". An ordinary pending + // row the winner's own record staged is a surviving + // spender's claim and stays in place; the tombstone is + // written beside it, leaving the same state as a stored + // loser's sweep, and the drain prefers the tombstone + // regardless of age. + val existing = db.documentDao().getPendingInputsByOutpoint(outpoint).firstOrNull { + it.walletId.contentEquals(walletId) && it.isSweptTombstone && + it.spendingTxid.contentEquals(winner) + } + if (existing == null) { + // `inputIndex` is the input's position in its spending + // transaction, which the batch does not carry; the + // drain copies it onto the TXO for display order only. + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = outpoint, + inputIndex = 0, + spendingTxid = winner, + spendingTransactionTxid = null, + walletId = walletId, + isSweptTombstone = true, + winnerMinedHeight = winnerHeight, + ), + ) + } else if (winnerHeight != null && existing.winnerMinedHeight != winnerHeight) { + // Same re-point rule as `stillHeld` above: a block-context + // sweep re-stamps, an IS-locked one keeps the stamp. + db.documentDao().updatePendingInputs( + listOf(existing.copy(winnerMinedHeight = winnerHeight)), + ) + } + } + for (outpoint in releasedOutpoints) { + db.txoDao().releaseByOutpoint(outpoint, walletId) + } + for (txid in txids) { + // Housekeeping only from here down: the row's ability to + // contribute funds was already durably cut off above, in + // every callback that reaches this point, independent of + // whether this delete ever fires. Deleting it when nothing + // else claims it just reclaims the now-inert row's storage. + if (!hasOtherWalletClaim(db, txid, walletId)) { + db.transactionDao().deleteByTxid(txid) + } + } + } + 0 + } + + /** + * Whether [txo]'s existing `spendingTxid` link must survive an arriving + * record ([newSpendingTxid], at [newContext]) that also claims the + * outpoint. See the guard's call site in `onWalletChangesetTransaction` + * for why a network-final spender's link is load-bearing: it is the only + * attribution the sweep release pass consults, and upstream cannot + * re-supply it for a spender it has pruned or lost across a restart. + * + * Kept when the existing spender's row still exists, has not been + * globally swept (a swept spender's claims were resolved by its own + * sweep — its link is dead weight a new spend may legitimately replace), + * and is network-final: IS-locked, in-block, or chainlocked + * (context >= [CONTEXT_INSTANT_SEND]). Two mempool spenders keep + * last-writer-wins, as before — neither claim outranks the other and a + * final winner sorts them out. The single sanctioned takeover mirrors + * DIP-10 precedence: a chainlocked arrival + * (context == [CONTEXT_CHAIN_LOCKED]) may take the coin from a spender + * that was only IS-locked — a plain in-block arrival may not, exactly as + * upstream's sweep gate refuses a plain block against a signed lock. A + * re-emit of the same spender is never a takeover. + */ + private suspend fun keepSettledSpenderLink( + db: DashDatabase, + txo: TxoEntity, + newSpendingTxid: ByteArray, + newContext: Int, + ): Boolean { + val existingTxid = txo.spendingTxid ?: return false + if (existingTxid.contentEquals(newSpendingTxid)) return false + val existing = db.transactionDao().getByTxid(existingTxid) ?: return false + if (existing.isGloballySwept) return false + if (existing.context < CONTEXT_INSTANT_SEND) return false + val chainlockOverIsLock = + newContext >= CONTEXT_CHAIN_LOCKED && existing.context == CONTEXT_INSTANT_SEND + return !chainlockOverIsLock + } + + /** + * Whether some wallet other than [walletId] still has a TXO or pending + * input pointing at [txid] as its spender, after this call's own + * hold/release/tombstone updates above have already cleared or detached + * everything [walletId] itself owns. See the class doc on + * [onWalletChangesetTransactionsSwept] for why this is what decides + * whether the shared `transactions` row is safe to physically reclaim + * yet — a housekeeping decision now, not a correctness one. + */ + private suspend fun hasOtherWalletClaim( + db: DashDatabase, + txid: ByteArray, + walletId: ByteArray, + ): Boolean = + db.txoDao().hasOtherWalletSpender(txid, walletId) || + db.documentDao().hasOtherWalletPendingInput(txid, walletId) + // ── Identities ──────────────────────────────────────────────────── override fun onPersistIdentityUpsert( @@ -2180,6 +2845,13 @@ class PlatformWalletPersistenceHandler( runBlockingResult { // walletId unused — txid is globally unique. val tx = database.transactionDao().getByTxid(txid) ?: return@runBlockingResult null + // A globally-swept row can still physically exist (another + // wallet's claim may not have cleared yet), but Rust has + // already proven it dead — treat it the same as "no such + // transaction" rather than handing back a body sent-payment + // reconciliation or the asset-lock proof flow would read as + // live. + if (tx.isGloballySwept) return@runBlockingResult null if (tx.transactionData.isEmpty()) return@runBlockingResult null if (tx.context >= CONTEXT_IN_BLOCK && (tx.blockHash == null || tx.blockHash.size != 32) @@ -2737,6 +3409,11 @@ class PlatformWalletPersistenceHandler( val outPoint = decodeOutPointHex(lock.outPointHex) ?: continue val txid = outPoint.copyOfRange(0, 32) val tx = database.transactionDao().getByTxid(txid) ?: continue + // A globally-swept funding tx lost a double-spend on one of its + // own inputs — it never confirms, so there is no unresolved + // asset lock left to restore it into. Skip rather than hand + // Rust a dead transaction to re-track. + if (tx.isGloballySwept) continue if (tx.transactionData.isEmpty()) continue out.add( UnresolvedAssetLockTxRecordData( @@ -3357,12 +4034,20 @@ 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 + internal const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = + NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL private const val TAG = "DashPersistence" + /** `TransactionContext::InstantSend` — network-final under DIP-10. */ + private const val CONTEXT_INSTANT_SEND = 1 + /** `TransactionContext::InBlock` — spends only count once in-block. */ private const val CONTEXT_IN_BLOCK = 2 + /** `TransactionContext::InChainLockedBlock` — outranks an IS lock. */ + private const val CONTEXT_CHAIN_LOCKED = 3 + /** * Rust `AssetLockStatus` wire bytes * (`wallet::asset_lock::tracked`): Built 0, Broadcast 1, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt index 92924b2ebc0..6e3fcea24f2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt @@ -172,8 +172,18 @@ interface AssetLockDao { @Delete suspend fun delete(assetLock: AssetLockEntity) - /** Consumed-lock removal path (`$0.outPointHex == hex`). */ - @Query("DELETE FROM asset_locks WHERE outPointHex = :outPointHex") + /** + * Asset-lock removal path (`onPersistAssetLockRemoval`). The + * `statusRaw != 4` guard is the same terminal rule SQLite's DELETE + * (`status != 'consumed'`) and Swift's `statusRaw == 4` skip apply: a + * Consumed row is deliberately retained for historical lookup, and + * neither removal producer can legitimately name one — a Built row + * rejected at broadcast never got that far, and a sweep of the funding + * transaction only tombstones entries still tracked, which a consumed + * lock no longer is — so a removal reaching a consumed row is by + * construction a stale write. + */ + @Query("DELETE FROM asset_locks WHERE outPointHex = :outPointHex AND statusRaw != 4") suspend fun deleteByOutPointHex(outPointHex: String) /** Wallet teardown mirror of `deleteWalletData`'s asset-lock pass. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt index 3f343d8d267..2c8293ae2ee 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt @@ -3,6 +3,7 @@ package org.dashfoundation.dashsdk.persistence.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Query +import androidx.room.Update import androidx.room.Upsert import kotlinx.coroutines.flow.Flow import org.dashfoundation.dashsdk.persistence.entities.DocumentEntity @@ -174,6 +175,98 @@ interface DocumentDao { @Query("SELECT * FROM pending_inputs WHERE walletId = :walletId") fun observePendingInputsByWallet(walletId: ByteArray): Flow> + /** + * This wallet's live pending inputs staged by [txid], for the sweep to + * partition in memory. + * + * The released set is not bound into SQL. It is bounded only by the + * input count of a transaction a remote sender can choose, so binding + * it one variable per outpoint can cross the 999-variable limit that + * API 29's framework SQLite still carries — the statement then throws, + * the whole atomic round fails, and the wallet's watermark freezes on a + * loser that will be re-swept into the same failure after every restart. + * Fetching by the two fixed keys and comparing outpoints against a + * `Set` keeps the arity constant no matter how large the sweep is. + */ + @Query( + "SELECT * FROM pending_inputs " + + "WHERE spendingTransactionTxid = :txid AND walletId = :walletId", + ) + suspend fun pendingInputsStagedBy(txid: ByteArray, walletId: ByteArray): List + + /** + * This wallet's tombstones already repointed at [txid] by an earlier + * sweep, found by the scalar `spendingTxid` — the only link left once + * the first tombstoning detached them from the relationship. Same + * fixed-arity discipline as [pendingInputsStagedBy]. + * + * A tombstone names one specific wallet's coin, so [walletId] must be + * the same wallet whose release decision is about to be applied; + * otherwise this would hand one wallet's claim to another's verdict. + */ + @Query( + "SELECT * FROM pending_inputs " + + "WHERE spendingTxid = :txid AND isSweptTombstone = 1 AND walletId = :walletId", + ) + suspend fun sweptTombstonesTargeting( + txid: ByteArray, + walletId: ByteArray, + ): List + + /** Per-row update; Room binds one row at a time, so arity is fixed. */ + @Update + suspend fun updatePendingInputs(rows: List) + + /** Per-row delete, same fixed-arity reason as [updatePendingInputs]. */ + @Delete + suspend fun deletePendingInputs(rows: List) + + /** + * Whether some wallet other than [walletId] still has a live pending + * input pointing at [txid] as its spending transaction. + * + * Mirrors [TxoDao.hasOtherWalletSpender] for the pending-input side of + * the same shared-row problem: [txid]'s `transactions` row is a + * statement about the transaction as a whole, so only the callback that + * finds no other wallet's claim left on it — TXO or pending input — is + * allowed to delete it. + */ + @Query( + "SELECT EXISTS(SELECT 1 FROM pending_inputs " + + "WHERE spendingTransactionTxid = :txid AND walletId != :walletId)", + ) + suspend fun hasOtherWalletPendingInput(txid: ByteArray, walletId: ByteArray): Boolean + + /** + * Bounded tombstone lifetime: delete this wallet's swept tombstones + * whose winner's mined height the chainlock finality boundary has + * reached (`:boundary` = `min(chainlockHeight, syncedHeight)`, + * computed by the caller) — key-wallet's + * `prune_finalized_observed_spends` condition verbatim, no + * observation-age margin: the stamp IS the winner's height, so at the + * boundary the funding transaction (mined at or below it) has been + * filter-scanned with no false negatives. A tombstone still + * collectible here never drained — its funding TXO never arrived — so + * the junk case (a foreign input of a swept incoming payment) is + * exactly what this removes; a genuine claim's row was already + * deleted by the drain that moved the hold onto the TXO. Unstamped + * rows are never collected — and they are a CURRENT, deliberate + * shape, not legacy data: a mempool-context sweep (IS-locked, unmined + * winner) writes its tombstone with a null stamp, because such a + * winner has no mining deadline and no boundary can prove the held + * funding delivered-or-never. An unstamped hold resolves only through + * proof — the funding TXO drains it, a later block-context sweep + * re-stamps it into this collector's reach, or a release deletes it — + * and holding an unresolved one forever is the contract, not a safe + * fallback. + */ + @Query( + "DELETE FROM pending_inputs " + + "WHERE walletId = :walletId AND isSweptTombstone = 1 " + + "AND winnerMinedHeight IS NOT NULL AND winnerMinedHeight <= :boundary", + ) + suspend fun collectFinalizedSweptTombstones(walletId: ByteArray, boundary: Int) + @Upsert suspend fun upsertPendingInput(pendingInput: PendingInputEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt index 322750d27a4..6bf67adede2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt @@ -69,6 +69,11 @@ interface TransactionDao { /** * Provider kinds 2…5 scoped through explicit account membership. The * ordering preserves Core's same-block transaction order when present. + * + * `isGloballySwept = 0` excludes a provider transaction that itself lost + * a double-spend on one of its own inputs — an edge case (most losers + * are ordinary spends), but a swept row is never restorable regardless + * of kind. See [TransactionEntity.isGloballySwept]. */ @Query( "SELECT DISTINCT transactions.* FROM transactions " + @@ -78,6 +83,7 @@ interface TransactionDao { "WHERE accounts.walletId = :walletId " + "AND accounts.accountType BETWEEN 8 AND 11 " + "AND transactions.transactionTypeKind BETWEEN 2 AND 5 " + + "AND transactions.isGloballySwept = 0 " + "ORDER BY transactions.blockHeight ASC, " + "transactions.hasBlockPosition DESC, transactions.blockPosition ASC, " + "transactions.firstSeen ASC" @@ -90,6 +96,16 @@ interface TransactionDao { @Delete suspend fun delete(transaction: TransactionEntity) + /** + * Durable global exclusion for a swept loser — set in EVERY wallet's + * `onWalletChangesetTransactionsSwept` callback that observes the sweep, + * not only the one whose [deleteByTxid] happens to remove the shared + * row. Idempotent: re-flagging an already-flagged row is a no-op. See + * [TransactionEntity.isGloballySwept]. + */ + @Query("UPDATE transactions SET isGloballySwept = 1 WHERE txid = :txid") + suspend fun markGloballySwept(txid: ByteArray) + @Query("DELETE FROM transactions WHERE txid = :txid") suspend fun deleteByTxid(txid: ByteArray) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt index 361c8e37b98..3e59b6a117b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt @@ -44,6 +44,173 @@ interface TxoDao { @Query("SELECT * FROM txos WHERE spendingTxid = :spendingTxid AND isSpent = 0") suspend fun getUnspentBySpendingTxid(spendingTxid: ByteArray): List + /** + * Hold every coin of [walletId]'s own that [spendingTxid] claimed out of + * the restore set, attributed to [supersededBy] rather than to a linked + * spender. + * + * Used when [spendingTxid] was swept: it can never confirm, so its claim + * is not a spend, but most of the coins it named really were taken — by + * the transaction that beat it. A swept transaction is always + * unconfirmed, so its inputs sit at `isSpent = 0`, and deleting it would + * otherwise return all of them, the consumed one included. + * + * `supersededByTxid` is what makes the hold durable — the same stamp the + * SQLite store writes as `spent_in_txid`, and the same one the pending- + * input drain writes when the claim had no TXO row yet. The winner need + * never be recorded here (it can pay only outside addresses), so the + * stamp cannot be a `spendingTxid` FK link; but leaving it off entirely + * would let the next re-delivery of the funding output — exactly what a + * restore-rescan does, blind to an unconfirmed winner it cannot see in + * any block — flip a provably-consumed coin back into the restore set. + * A stamped hold only ever comes free through an explicit release + * ([releaseByOutpoint], which clears the stamp with the hold). + * + * [spendingTxid] can be shared: the same `transactions` row spends coins + * from more than one wallet at once, and upstream computes a separate + * released set per wallet (`per_wallet_released_outpoints`). This + * wallet's set has no say over a coin a *different* wallet owns, so the + * `walletId` filter keeps this call from holding a coin some other + * wallet's own callback — already run, still to come, or never coming + * at all — is the only one entitled to decide. + * + * Run this *before* deleting the transaction, while the link that + * identifies those rows is still there — the foreign key nulls + * `spendingTxid` on delete, and afterwards nothing finds them. Then + * clear the genuinely free ones with [releaseByOutpoint]. + */ + @Query( + "UPDATE txos SET isSpent = 1, spendingTxid = NULL, spendingInputIndex = NULL, " + + "supersededByTxid = :supersededBy " + + "WHERE spendingTxid = :spendingTxid AND walletId = :walletId", + ) + suspend fun holdSpentWithoutSpender( + spendingTxid: ByteArray, + walletId: ByteArray, + supersededBy: ByteArray, + ) + + /** + * Mark one outpoint of [walletId]'s own unspent again — a coin a sweep + * released, meaning no surviving transaction spent it *at the time the + * sweep was computed*. + * + * Keyed by outpoint rather than by spender because that is how upstream + * reports it: the transaction that took the other inputs may never be + * recorded here at all, so the released set is the only authority on + * which coins came free. + * + * `spendingTxid IS NULL` is what keeps that from overreaching. A round + * can carry both a release and a later transaction that legitimately + * spends the freed coin — merging folds several events together, and + * every record is written before sweeps are processed — so by the time + * this runs the coin may already be claimed again. Only rows + * [holdSpentWithoutSpender] just detached qualify; anything a live + * transaction still claims keeps that claim. The `walletId` filter is + * the same ownership guard as [holdSpentWithoutSpender]: a released set + * is only ever true of the wallet that computed it, so it should never + * be able to touch another wallet's row even if an outpoint were ever + * to collide. + * + * `supersededByTxid` clears in the same statement, the way the SQLite + * store's release UPDATE clears `spent_in_txid`. A chained sweep can + * free a coin whose claim already drained into that column (the funding + * TXO arrived between the sweep that held it and the one now freeing + * it), and this call is the only writer that ever clears it — a + * released coin keeping its dead winner's marker would turn the next + * hold on this outpoint permanent, because the redelivery carry-over in + * `onWalletChangesetUtxoAdded` reads a present marker as a durable + * claim and refuses to lift `isSpent` ever again. + */ + @Query( + "UPDATE txos SET isSpent = 0, spendingInputIndex = NULL, supersededByTxid = NULL " + + "WHERE outpoint = :outpoint AND spendingTxid IS NULL AND walletId = :walletId", + ) + suspend fun releaseByOutpoint(outpoint: ByteArray, walletId: ByteArray) + + /** + * Hold one of [walletId]'s coins spent under [supersededBy] on the + * strength of a sweep batch's claimed inputs alone — the by-outpoint + * twin of [holdSpentWithoutSpender] for a loser this store never held + * (swept before its own detection reached persistence), whose inputs + * therefore cannot be found by spender. + * + * A detached coin qualifies, as does one already linked to + * [supersededBy]: the winner's record can establish that valid link + * before an unconfirmed spend is marked, and the sweep must keep the + * link while applying its durable hold. A link to any different + * surviving spender is left alone. Returns the rows touched, so the + * caller can distinguish a coin it held from an input whose funding row + * has not materialized yet and needs a placeholder instead. + */ + @Query( + "UPDATE txos SET isSpent = 1, spendingInputIndex = NULL, " + + "supersededByTxid = :supersededBy " + + "WHERE outpoint = :outpoint " + + "AND (spendingTxid IS NULL OR spendingTxid = :supersededBy) " + + "AND walletId = :walletId", + ) + suspend fun holdClaimedInput( + outpoint: ByteArray, + walletId: ByteArray, + supersededBy: ByteArray, + ): Int + + /** + * Apply a sweep's durable hold to every coin of [walletId] already + * attributed to [winnerTxid]. A persisted winner can take the input link + * from the loser before the loser is swept, so the loser-based update no + * longer finds these rows even though the winner's unconfirmed record has + * not marked them spent yet. + * + * The winner link and input index stay intact: they are valid attribution, + * and matching by [winnerTxid] leaves every different surviving spender + * untouched. + */ + @Query( + "UPDATE txos SET isSpent = 1, supersededByTxid = :winnerTxid " + + "WHERE spendingTxid = :winnerTxid AND walletId = :walletId", + ) + suspend fun holdInputsLinkedToWinner(winnerTxid: ByteArray, walletId: ByteArray) + + /** + * Whether some wallet other than [walletId] still has a TXO pointing at + * [spendingTxid] as its spender. + * + * `transactions` rows are shared across wallets — the same on-chain tx + * can spend coins from several of them — so [spendingTxid]'s row is a + * statement about the transaction as a whole and only one wallet's + * callback should ever delete it. This is the check that lets each + * callback decide whether it is that one: after [holdSpentWithoutSpender] + * and [releaseByOutpoint] have applied *this* wallet's own decisions + * (which always clear or detach its own rows), anything still pointing + * at [spendingTxid] belongs to a wallet that has not weighed in yet, and + * the delete has to wait for it. + */ + @Query("SELECT EXISTS(SELECT 1 FROM txos WHERE spendingTxid = :spendingTxid AND walletId != :walletId)") + suspend fun hasOtherWalletSpender(spendingTxid: ByteArray, walletId: ByteArray): Boolean + + /** + * Delete every TXO [txid] itself created — its own outputs — independent + * of whether the `transactions` row for [txid] is deleted in the same + * call. + * + * Ordinarily the FK from `txos.txid` to `transactions.txid` (CASCADE) + * would do this for free, but only once the parent row is deleted, and + * [TransactionDao.deleteByTxid] deliberately withholds that delete for + * as long as another wallet still has a claim on the row — which can be + * indefinite if that wallet's own callback is rejected or never arrives. + * These outputs are nobody's coin, ever, regardless: a transaction that + * can never confirm funded nothing, for every wallet, not just the one + * whose callback happens to run. [onWalletChangesetTransactionsSwept] + * calls this in EVERY wallet's callback that observes the sweep, so the + * deletion is durable from the first one rather than waiting on + * whichever happens to be last. Idempotent — a row with no outputs left + * is a no-op. + */ + @Query("DELETE FROM txos WHERE txid = :txid") + suspend fun deleteOwnOutputs(txid: ByteArray) + /** * Flip `isSpent` on every still-unspent TXO consumed by * [spendingTxid] — the heal a finalized asset lock drives when SPV diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt index ef9b4c5c019..9203a8f9ee5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt @@ -1,5 +1,6 @@ package org.dashfoundation.dashsdk.persistence.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index @@ -53,4 +54,51 @@ data class PendingInputEntity( /** Wallet id denorm for cleanup / per-wallet diagnostics. */ val walletId: ByteArray, val createdAt: Date = Date(), + /** + * Port of Swift `PersistentPendingInput.isSweptTombstone`. Set by + * `onWalletChangesetTransactionsSwept` when this row's spend turns out + * to belong to a swept loser and the input wasn't in `released`: + * [spendingTransactionTxid] is cleared (detaching the FK so the row + * survives the loser's cascade-delete) and [spendingTxid] is + * overwritten with the winner's txid. `onWalletChangesetUtxoAdded` + * checks this flag when it later drains the row — a tombstone forces + * `TxoEntity.isSpent = true` unconditionally (a sweep's winner is + * already final, unlike an ordinary pending spend whose confirmation is + * still pending) and stamps `TxoEntity.supersededByTxid` so the mark + * survives even when the winner's own `transactions` row never + * materializes. Defaulted `false` so pre-migration rows read as + * ordinary pending entries. + * + * Declares its default so the exported schema agrees with what + * `MIGRATION_10_11` writes: SQLite requires one on a NOT NULL + * `ADD COLUMN`, and Room compares defaults when validating a migrated + * database against the entity — a mismatch fails the upgrade outright. + */ + @ColumnInfo(defaultValue = "0") + val isSweptTombstone: Boolean = false, + /** + * The mined block height of the WINNER that swept this tombstone's + * loser — the winner's own height, carried on the sweep event itself, + * not any observation watermark. This stamp is the row's whole + * lifetime rule: the collector deletes the tombstone once the + * chainlock finality boundary `min(chainlockHeight, syncedHeight)` + * reaches it — key-wallet's `prune_finalized_observed_spends` + * condition verbatim, no observation-age margin — because at that + * boundary the funding transaction (necessarily mined at or below + * the winner's height) has been filter-scanned with no false + * negatives, so an undrained row is provably not the wallet's coin. + * A genuine claim drains into its TXO on funding arrival and leaves + * the collectible set with the row. + * + * NULL is never collected. A mempool/IS-context sweep (unmined + * winner) writes its tombstone unstamped on purpose: under DIP-10 + * the IS lock alone settles the input, but the winner has no mining + * deadline, so no boundary can ever prove its funding output + * delivered-or-never — the hold lasts until the funding TXO drains + * it, a later block-context sweep stamps it, or a release deletes + * it. An IS-locked re-point likewise keeps the existing stamp. + * Nullable, so the ADD COLUMN migration needs no default and + * pre-migration rows read as unstamped. + */ + val winnerMinedHeight: Int? = null, ) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TransactionEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TransactionEntity.kt index f48b9d626aa..a2c31d9c637 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TransactionEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TransactionEntity.kt @@ -1,5 +1,6 @@ package org.dashfoundation.dashsdk.persistence.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -54,6 +55,23 @@ data class TransactionEntity( val firstSeen: Long = 0, val createdAt: Date = Date(), val lastUpdated: Date = Date(), + /** + * Port of Swift `PersistentTransaction.isGloballySwept`. Set by + * `onWalletChangesetTransactionsSwept` in EVERY wallet's callback that + * observes this row's sweep, not only the one whose + * `TransactionDao.deleteByTxid` happens to remove it — see that + * function's class doc for why the physical delete alone is not durable + * enough (each wallet's `store()` commits independently). `true` means + * Rust has already proven this transaction can never confirm; every + * restore/enumeration query must exclude the row regardless of whether + * it still physically exists. + * + * Declares its default so the exported schema agrees with what + * `MIGRATION_11_12` writes (see `PendingInputEntity.isSweptTombstone` + * for why this is required, not optional). + */ + @ColumnInfo(defaultValue = "0") + val isGloballySwept: Boolean = false, ) { override fun equals(other: Any?): Boolean = other is TransactionEntity && txid.contentEquals(other.txid) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt index 9f3255848b4..bc713154c88 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt @@ -77,7 +77,10 @@ data class TxoEntity( val isConfirmed: Boolean = false, val isInstantLocked: Boolean = false, val isLocked: Boolean = false, - /** Denormalized `spendingTxid != null`; kept explicit (hot filter path). */ + /** + * Denormalized `spendingTxid != null || supersededByTxid != null`; kept + * explicit (hot filter path). + */ val isSpent: Boolean = false, val createdAt: Date = Date(), val lastUpdated: Date = Date(), @@ -100,6 +103,30 @@ data class TxoEntity( * navigation pointer. */ val coreAddressId: String? = null, + /** + * Port of Swift `PersistentTxo.supersededByTxid` — the winner a sweep + * attributed this coin's consumption to, mirroring the SQLite store's + * `spent_in_txid`. Sweep processing sets it when holding an + * already-materialized input, whether that input is still linked to the + * loser or already linked to the winner; the other writer is + * `onWalletChangesetUtxoAdded` resolving a `pending_inputs` row with + * `isSweptTombstone` — the funding output arrived only after the loser + * that spent it was swept and deleted. Deliberately NOT an FK: the + * winner named here need not have its own `transactions` row (it can be + * wallet-irrelevant), so this column has to hold a bare txid that + * `transactions(txid)` may never contain. + * + * The stamp is what makes a hold durable. The `isSpent` carry-over + * above and `onWalletChangesetUtxoAdded`'s recovery clear both key on + * it: a coin the wallet re-delivers as unspent only lifts `isSpent` + * when both `spendingTxid` and this are null — a rescan re-finds the + * funding output precisely because it is blind to an unconfirmed + * winner no block carries yet, so re-delivery cannot outrank the + * sweep's verdict. Cleared only by `releaseByOutpoint`, when a later + * sweep proves the coin came free after all; a pre-stamp row (written + * before holds named their winner) still frees on re-delivery. + */ + val supersededByTxid: ByteArray? = null, ) { override fun equals(other: Any?): Boolean = other is TxoEntity && outpoint.contentEquals(other.outpoint) 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 fcf5d678420..8766962dd03 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 @@ -55,6 +55,17 @@ data class WalletEntity( * Opaque passthrough — decoded only by Rust; never re-encoded here. */ val lastAppliedChainLockBytes: ByteArray? = null, + /** + * The numeric block height of the last applied chainlock, delivered + * separately by `onWalletChangesetChainLockHeight` (the bincode blob + * above is opaque on this side of the FFI). Monotonic max — a stale + * round never lowers it. This is the chainlock half of the swept- + * tombstone collection boundary `min(chainlockHeight, syncedHeight)`; + * while NULL no finality boundary exists and the collector never + * runs, mirroring the SQLite store's "no-op until a chainlock height + * has been persisted". + */ + val lastAppliedChainLockHeight: Int? = null, val isImported: Boolean = false, val createdAt: Date = Date(), val lastUpdated: Date = Date(), diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index d8f0cf26b6f..904bcc7c86d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -61,6 +61,30 @@ data class PlatformWalletPersistenceCapabilities( const val WALLET_RESTORE: Long = 1L shl 7 const val DPNS_NAME_STATES: Long = 1L shl 8 const val TRACKED_ASSET_LOCKS: Long = 1L shl 9 + /** + * Tracked (wallet-independent) masternodes are persisted and + * restored across restarts. Mirrors + * `PersistenceCapabilities::TRACKED_MASTERNODES`. Android does not + * wire the trio and never attests this bit; the constant exists so + * the diagnostic mirror stays bit-for-bit with Rust. + */ + const val TRACKED_MASTERNODES: Long = 1L shl 10 + /** + * A stored core changeset's non-empty sweeps are durably applied + * batch by batch and in order: each swept transaction and its + * outputs are excluded from every restore and enumeration path + * (physical deletion or a durable marker alike — an inert + * globally-swept row may remain until every wallet's scoped + * cleanup lands), each released outpoint is freed unless a later + * surviving claim supersedes that release, and each non-released + * input RETAINS a durable spend claim even when its funding TXO + * has not materialized yet — a detached claim must outlive its + * loser, or a post-restart funding delivery credits a coin the + * network already consumed. Physical row deletion is an + * implementation detail, not the contract. Mirrors + * `PersistenceCapabilities::CORE_SWEEP_REMOVAL`. + */ + const val CORE_SWEEP_REMOVAL: Long = 1L shl 11 } } 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 5008ade780b..972db383a96 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 @@ -22,6 +22,7 @@ import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity import org.dashfoundation.dashsdk.persistence.entities.TransactionEntity import org.dashfoundation.dashsdk.persistence.entities.TxoEntity import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity +import org.dashfoundation.dashsdk.persistence.entities.PendingInputEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.WalletEntity import org.junit.After @@ -77,7 +78,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0x3bfL, handler.persistenceCapabilitiesBits()) + assertEquals(0xbbfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) @@ -90,6 +91,38 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.INVITATIONS)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.DPNS_NAME_STATES)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.TRACKED_ASSET_LOCKS)) + assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.CORE_SWEEP_REMOVAL)) + } + + @Test + fun sweepSlotDefaultRefusesARoundOnlyWhenTheCapabilityIsHandDeclared() { + // The trampoline is wired for every subclass, so "slot present" + // proves nothing — the contract lives in the capability bit. A + // subclass declaring CORE_SWEEP_REMOVAL without overriding the slot + // has promised removals it would silently swallow while the + // watermark advances; the inherited default must refuse the round + // instead. One that declares nothing keeps the benign ignore: Rust + // strips the watermark before its store(), and failing the round + // would throw away its additive slots for no protection gained. + val declaringButNotOverriding = object : NativePersistenceBridge() { + override fun persistenceCapabilitiesBits(): Long = + NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + } + val walletId = ByteArray(32) { 1 } + assertTrue( + "a hand-declared capability with the inherited no-op body must fail the round", + declaringButNotOverriding.onWalletChangesetTransactionsSwept( + walletId, arrayOf(ByteArray(32) { 2 }), arrayOf(ByteArray(32) { 3 }), emptyArray(), emptyArray(), 400, + ) != 0, + ) + + val nonAttesting = object : NativePersistenceBridge() {} + assertEquals( + 0, + nonAttesting.onWalletChangesetTransactionsSwept( + walletId, arrayOf(ByteArray(32) { 2 }), arrayOf(ByteArray(32) { 3 }), emptyArray(), emptyArray(), 400, + ), + ) } // ── Standalone (non-bracketed) writes ───────────────────────────── @@ -2046,14 +2079,15 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun spendBeforeFundingReconcilesViaPendingInputAndExcludesFromRestore() = runTest { - // CORE-06, out-of-order arrival: an in-block spending tx is persisted - // BEFORE its funding TXO is known (Rust's utxos_spent slice is empty - // because the previous output wasn't classified yet). The spend must - // not be lost — `inputOutpoints` stages a pending-input row that the - // funding TXO's later upsert drains, so the consumed output is excluded - // from the restore set instead of being handed back to Rust as - // spendable. 1:1 mirror of Swift resolveInputOutpoint + upsertUtxo drain. + fun sweptTransactionIsDeletedAndFreesOnlyItsOwnInputs() = runTest { + // A recorded spend that a later, final transaction beat to an input + // can never confirm; Rust drops it and names it here. The mirror has + // to drop it too — otherwise the row comes back on the next load and + // re-creates a balance the wallet already corrected. + // + // Shape: the loser (unconfirmed, as every swept loser is) spends A + // and B; the winner is wallet-relevant, in-block, and takes only A. + // A must stay out of the restore set, B must return to it. handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) val xpub = ByteArray(78) { 30 } handler.onPersistAccountRegistration( @@ -2062,7 +2096,7 @@ class PlatformWalletPersistenceHandlerTest { val account = db.accountDao().observeByWallet(walletId).first().single() db.coreAddressDao().upsert( CoreAddressEntity( - address = "yFundAddr", + address = "yUtxoAddr", poolTypeTag = 0, addressIndex = 0, derivationPath = "m/44'/1'/0'/0/0", @@ -2071,585 +2105,2718 @@ class PlatformWalletPersistenceHandlerTest { ) val fundingTxid = ByteArray(32) { 41 } - val spendingTxid = ByteArray(32) { 42 } + val sweptTxid = ByteArray(32) { 42 } + val winnerTxid = ByteArray(32) { 44 } - // Changeset 1: the in-block spending tx arrives first. Its funding TXO - // is unknown, so a pending-input row is staged (no utxos_spent fires). handler.onChangesetBegin(walletId) handler.onWalletChangesetTransaction( - walletId, spendingTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, - 1_700_000_200, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_100, - makeOutpoint(fundingTxid, 0), 1, // spends fundingTxid:0 (TXO unknown) + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 140_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + // A (vout 0) and B (vout 1). + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 1, 40_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, ) handler.onChangesetEnd(walletId, success = true) - val staged = db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)) - assertEquals(1, staged.size) - assertTrue(spendingTxid.contentEquals(staged.single().spendingTxid)) - // Funding TXO absent → nothing to restore yet. - assertEquals(0, handler.onLoadWalletList().single().utxos.size) - - // Changeset 2: the funding TXO finally lands. The drain links the spend - // (in-block → isSpent) and clears the pending row. + // The doomed transaction: mempool context — upstream only ever + // sweeps unconfirmed records, so its inputs are linked to it without + // `isSpent` ever flipping. handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0) + makeOutpoint(fundingTxid, 1), 2, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, sweptTxid) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 1, sweptTxid) handler.onWalletChangesetUtxoAdded( - walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, - 100, false, true, false, false, + walletId, sweptTxid, 0, 60_000, "yUtxoAddr", ByteArray(25) { 6 }, + 0, false, false, false, false, ) handler.onChangesetEnd(walletId, success = true) + assertFalse( + "a pre-block spender links but must not flip isSpent", + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent, + ) - val txo = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) - assertNotNull(txo) - assertTrue(txo!!.isSpent) - assertTrue(spendingTxid.contentEquals(txo.spendingTxid!!)) - assertTrue( - db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)).isEmpty(), + // The winner confirms, taking A, then the sweep runs — the ordering + // the persist path guarantees inside one round. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, winnerTxid, ByteArray(10) { 6 }, 2, 102, ByteArray(32) { 9 }, + 1_700_000_200, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_150, + makeOutpoint(fundingTxid, 0), 1, ) - // The consumed output must NOT be handed back to Rust as spendable. - assertEquals(0, handler.onLoadWalletList().single().utxos.size) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), + arrayOf(makeOutpoint(fundingTxid, 1)), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + assertNull("the swept transaction row is gone", db.transactionDao().getByTxid(sweptTxid)) + assertNull( + "the change it created is gone with it", + db.txoDao().getByOutpoint(makeOutpoint(sweptTxid, 0)), + ) + assertNotNull("the funding transaction is untouched", db.transactionDao().getByTxid(fundingTxid)) + + val winnerTaken = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! + assertTrue("the coin the winner took stays spent", winnerTaken.isSpent) + assertTrue(winnerTxid.contentEquals(winnerTaken.spendingTxid)) + + // B was only ever claimed by the loser, so it is spendable again. + val released = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 1))!! + assertFalse("the loser's own input is free again", released.isSpent) + assertNull(released.spendingTxid) + val restored = handler.onLoadWalletList().single().utxos.single() + assertEquals(1, restored.vout) } @Test - fun loadWalletListRestoresCoreAddressPoolsBeyondGapWindow() = runTest { - // prior-2 regression: the persisted Core address pools must come - // back on the restore row so every restored address maps to its - // derivation path — including addresses PAST the gap-limit window - // (`DEFAULT_GAP_LIMIT` = 20) that `ManagedWalletInfo::from_wallet` - // pre-derives. Without this, a restored UTXO on an out-of-window - // address has no derivation-path mapping and the wallet cannot - // sign a core-to-core spend after a cold restart. Mirror of the - // Swift `buildCoreAddressPoolBuffer` round-trip. + fun anAbsentWinnerStillKeepsItsOwnInputSpent() = runTest { + // The winner can spend our coin and pay only outside addresses. It + // sweeps the loser all the same, but no record for it ever reaches + // the persister — so nothing in this store could work out that the + // coin is gone. Upstream can, and reports it by leaving the coin out + // of the released set. A swept loser is unconfirmed, so its input is + // linked at `isSpent = 0`; deleting the loser and stopping there + // would return a coin the chain has already spent as spendable. 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 account = db.accountDao().observeByWallet(walletId).first().single() - - // An external (pool tag 0) address well beyond the gap window, - // used and carrying a balance + a full derivation path + pubkey. - val pubkey = ByteArray(33) { 4 } db.coreAddressDao().upsert( CoreAddressEntity( - address = "yFarAddr", - publicKey = pubkey, + address = "yUtxoAddr", poolTypeTag = 0, - addressIndex = 100, - derivationPath = "m/44'/1'/0'/0/100", - isUsed = true, - balance = 12_345, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", accountId = account.id, ), ) - // A second, unused internal (pool tag 1) address — proves grouping - // by pool type emits a distinct pool for the change chain. - db.coreAddressDao().upsert( - CoreAddressEntity( - address = "yChangeAddr", - publicKey = ByteArray(0), - poolTypeTag = 1, - addressIndex = 3, - derivationPath = "m/44'/1'/0'/1/3", - isUsed = false, - accountId = account.id, - ), + + val fundingTxid = ByteArray(32) { 45 } + val sweptTxid = ByteArray(32) { 46 } + val irrelevantWinner = ByteArray(32) { 47 } + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) - val list = handler.onLoadWalletList() - assertEquals(1, list.size) - val pools = list[0].coreAddressPools - // One pool per (account, poolType) group, ascending tag order. - assertEquals(2, pools.size) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, sweptTxid) + handler.onChangesetEnd(walletId, success = true) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent) - val external = pools[0] - assertEquals(0.toByte(), external.poolTypeTag) - // The pool routes via the account tuple (xpub omitted — the loader - // ignores it on this path). - assertEquals(0.toByte(), external.account.typeTag) - assertEquals(0, external.account.index) - assertEquals(0, external.account.accountXpubBytes.size) - assertEquals(1, external.addresses.size) - val far = external.addresses[0] - assertEquals("yFarAddr", far.addressBase58) - // The out-of-window address keeps its derivation path — the whole - // point of the fix. - assertEquals("m/44'/1'/0'/0/100", far.derivationPath) - assertEquals(100, far.addressIndex) - assertTrue(far.isUsed) - assertEquals(12_345L, far.balance) - assertTrue(pubkey.contentEquals(far.publicKey)) - assertEquals(0.toByte(), far.poolTypeTag) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sweptTxid), arrayOf(irrelevantWinner), + // Upstream knows the winner took this coin even though it never + // reports the winner itself, so nothing is released. + emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) - val internal = pools[1] - assertEquals(1.toByte(), internal.poolTypeTag) - assertEquals(1, internal.addresses.size) - val change = internal.addresses[0] - assertEquals("yChangeAddr", change.addressBase58) - assertEquals("m/44'/1'/0'/1/3", change.derivationPath) - assertEquals(3, change.addressIndex) - assertFalse(change.isUsed) - // No pubkey persisted → empty (Rust derives has_public_key = false). - assertEquals(0, change.publicKey.size) + assertNull(db.transactionDao().getByTxid(sweptTxid)) + val held = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! + assertTrue("the coin the unrecorded winner may have taken is held", held.isSpent) + assertNull("with no spender invented for it", held.spendingTxid) + assertTrue( + "but with the winner stamped, the same attribution SQLite " + + "records as spent_in_txid", + irrelevantWinner.contentEquals(held.supersededByTxid), + ) + assertTrue( + "and it stays out of the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + + // A re-delivery of the funding output — what a restore-rescan does, + // blind to the unconfirmed winner no block carries yet — must NOT + // outrank the sweep's verdict: the coin was provably consumed, and + // handing it back would resurrect it into the restore set on every + // restore-from-seed until the winner confirms. Only an explicit + // release frees a stamped hold — the same answer the SQLite store's + // upsert valve gives to the identical event stream. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val redelivered = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! + assertTrue("the stamped hold survives re-delivery", redelivered.isSpent) + assertTrue(irrelevantWinner.contentEquals(redelivered.supersededByTxid)) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) } @Test - fun loadWalletListRoundTripsIdentityKeysWithContractBounds() = runTest { - // Signing-critical restore path: a cold-started wallet must get its - // identities and public keys back exactly as persisted — keyId, - // repr(u8) discriminants, key bytes, and the (kind, id, docType) - // contract-bounds triple (kind 2 = SingleContractDocumentType). + fun aLoserSweptBeforeItsDetectionPersistedStillHoldsTheClaimedInput() = runTest { + // The loser can be swept while its detection event is still queued + // for persistence: the projection then finds no live record and + // refuses to restore the stale row, so this store never holds a + // transaction whose inputs the sweep could walk. The detection's + // own input details ride on the batch as `claimedInputs` instead, + // and must be settled exactly like a deleted row's inputs — held + // under the winner, durably, across a restart AND a re-delivery of + // the funding output. 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 identityId = ByteArray(32) { 12 } - seedIdentity(identityId) - val pubkey = ByteArray(33) { 7 } - val boundsId = ByteArray(32) { 21 } + val account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yUtxoAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 61 } + val neverHeldLoser = ByteArray(32) { 62 } + val externalWinner = ByteArray(32) { 63 } + val funding = makeOutpoint(fundingTxid, 0) handler.onChangesetBegin(walletId) - handler.onPersistIdentityKeyUpsert( - walletId = walletId, - identityId = identityId, - keyId = 4, - purpose = 1, - securityLevel = 2, - keyType = 0, - readOnly = true, - disabledAtIsSome = false, - disabledAt = 0, - publicKeyData = pubkey, - publicKeyHash = ByteArray(20), - walletIdIsSome = true, - keyWalletId = walletId, - derivationIndicesIsSome = false, - identityIndex = 0, - keyIndex = 0, - contractBoundsKind = 2, - contractBoundsId = boundsId, - contractBoundsDocumentType = "contactRequest", + handler.onWalletChangesetTransaction( + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, ) handler.onChangesetEnd(walletId, success = true) + assertFalse(db.txoDao().getByOutpoint(funding)!!.isSpent) - val list = handler.onLoadWalletList() - assertEquals(1, list.size) - assertEquals(1, list[0].identities.size) - val identity = list[0].identities[0] - assertTrue(identityId.contentEquals(identity.identityId)) - assertEquals(1, identity.keys.size) - val key = identity.keys[0] - assertEquals(4, key.keyId) - assertEquals(0.toByte(), key.keyType) - assertEquals(1.toByte(), key.purpose) - assertEquals(2.toByte(), key.securityLevel) - assertTrue(key.readOnly) - assertTrue(pubkey.contentEquals(key.data)) - assertEquals(2.toByte(), key.contractBoundsKind) - assertTrue(boundsId.contentEquals(key.contractBoundsId)) - assertEquals("contactRequest", key.contractBoundsDocumentType) - } + // The round the adapter builds for a delayed detection folded with + // its sweep: no record, no utxos_spent, one batch vouching for the + // funding coin. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(neverHeldLoser), arrayOf(externalWinner), + emptyArray(), arrayOf(funding), 400, + ) + handler.onChangesetEnd(walletId, success = true) - // ── DashPay contacts: upsert metadata, ignore delta, restore ────── + assertNull("no stale loser row is invented", db.transactionDao().getByTxid(neverHeldLoser)) + val held = db.txoDao().getByOutpoint(funding)!! + assertTrue("the input the batch vouched for is held", held.isSpent) + assertNull(held.spendingTxid) + assertTrue(externalWinner.contentEquals(held.supersededByTxid)) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) - /** Persist one incoming contact row for [senderId] owned by [ownerId]. */ - private suspend fun persistIncomingContact(ownerId: ByteArray, senderId: ByteArray) { + // Re-delivery of the funding output (restore-rescan) must not lift + // the hold — the same valve the SQLite store's upsert applies. handler.onChangesetBegin(walletId) - handler.onPersistContactUpsert( - walletId = walletId, - ownerId = ownerId, - contactId = senderId, - isOutgoing = false, - senderKeyIndex = 2, - recipientKeyIndex = 3, - accountReference = 4, - encryptedPublicKey = ByteArray(96) { 5 }, - encryptedAccountLabel = ByteArray(3) { 6 }, - autoAcceptProof = null, - coreHeightCreatedAt = 100_000, - createdAt = 1_700_000_000_000, - paymentChannelBroken = true, - alias = "ally", - note = "a note", - isHidden = true, - contactAccountLabel = "Main wallet", - acceptedAccounts = intArrayOf(0, 7), + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, ) handler.onChangesetEnd(walletId, success = true) + val redelivered = db.txoDao().getByOutpoint(funding)!! + assertTrue("the claimed-input hold survives re-delivery", redelivered.isSpent) + assertTrue(externalWinner.contentEquals(redelivered.supersededByTxid)) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) } @Test - fun contactUpsertPersistsEstablishedRowMetadata() = runTest { - // The contactInfo / DIP-15 metadata block added by upstream #3841 - // must land in Room — dropping it here would wipe alias/note/hidden - // (and the broken-channel flag) on Android relative to Swift. - val ownerId = ByteArray(32) { 15 } - val senderId = ByteArray(32) { 16 } - seedIdentity(ownerId) - persistIncomingContact(ownerId, senderId) - - val rows = db.dashpayDao().getContactRequestsByOwner(ownerId) - assertEquals(1, rows.size) - val row = rows[0] - assertTrue(row.paymentChannelBroken) - assertEquals("ally", row.contactAlias) - assertEquals("a note", row.contactNote) - assertTrue(row.contactHidden) - assertEquals("Main wallet", row.contactAccountLabel) - assertTrue( - intArrayOf(0, 7).contentEquals(decodeAcceptedAccounts(row.contactAcceptedAccounts)), + fun aClaimedInputAlreadyLinkedToTheWinnerIsStillHeld() = 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), ) - } - @Test - fun contactIgnoreDeltaDropsIncomingRowAndRoundTripsIgnoredSender() = runTest { - // Ignore (isIgnored=true): the sender's incoming row goes and a - // durable ignored-sender row appears; un-ignore deletes it again. - val ownerId = ByteArray(32) { 17 } - val senderId = ByteArray(32) { 18 } - seedIdentity(ownerId) - persistIncomingContact(ownerId, senderId) + val fundingTxid = ByteArray(32) { 67 } + val funding = makeOutpoint(fundingTxid, 0) + val absentLoser = ByteArray(32) { 68 } + val winner = ByteArray(32) { 69 } handler.onChangesetBegin(walletId) - assertEquals(0, handler.onPersistContactIgnored(walletId, ownerId, senderId, true)) + handler.onWalletChangesetTransaction( + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 50_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) handler.onChangesetEnd(walletId, success = true) - assertTrue(db.dashpayDao().getContactRequestsByOwner(ownerId).isEmpty()) - val ignored = db.dashpayDao().getIgnoredSendersByOwner(ownerId) - assertEquals(1, ignored.size) - assertTrue(senderId.contentEquals(ignored[0].ignoredSenderId)) - assertEquals(testnet, ignored[0].networkRaw) + // The InstantSend winner is known and linked, but its unconfirmed + // record does not spend-mark the coin yet. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, winner, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + funding, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winner) + handler.onChangesetEnd(walletId, success = true) + val linked = db.txoDao().getByOutpoint(funding)!! + assertFalse(linked.isSpent) + assertTrue(winner.contentEquals(linked.spendingTxid)) + // The loser was swept before this store ever held its row. Its raw + // input claim must hold the already-linked coin under the winner. handler.onChangesetBegin(walletId) - assertEquals(0, handler.onPersistContactIgnored(walletId, ownerId, senderId, false)) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(absentLoser), arrayOf(winner), + emptyArray(), arrayOf(funding), -1, + ) handler.onChangesetEnd(walletId, success = true) - assertTrue(db.dashpayDao().getIgnoredSendersByOwner(ownerId).isEmpty()) + + val held = db.txoDao().getByOutpoint(funding)!! + assertTrue("a valid winner link does not replace the durable hold", held.isSpent) + assertTrue(winner.contentEquals(held.spendingTxid)) + assertTrue(winner.contentEquals(held.supersededByTxid)) + assertTrue( + "the winner-held coin stays out of the restored UTXO set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) } @Test - fun loadWalletListRoundTripsContactsAndIgnoredSenders() = runTest { - // Relaunch-durability: the restore rows must carry the contact - // (with its metadata) and the ignored-sender id back to Rust, or - // contact metadata is wiped and ignored senders resurface after - // every cold start. + fun aPersistedLoserStillHoldsItsInputAfterTheWinnerTakesTheLink() = runTest { + val androidContext = ApplicationProvider.getApplicationContext() + val databaseName = "persisted-loser-winner-link-${java.util.UUID.randomUUID()}" + db.close() + db = Room.databaseBuilder(androidContext, DashDatabase::class.java, databaseName) + .allowMainThreadQueries() + .build() + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + 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) { 19 } - val contactId = ByteArray(32) { 20 } - val mutedId = ByteArray(32) { 21 } - seedIdentity(ownerId) - persistIncomingContact(ownerId, contactId) + val account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 67 } + val funding = makeOutpoint(fundingTxid, 0) + val loser = ByteArray(32) { 68 } + val winner = ByteArray(32) { 69 } + handler.onChangesetBegin(walletId) - handler.onPersistContactIgnored(walletId, ownerId, mutedId, true) + handler.onWalletChangesetTransaction( + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 50_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) handler.onChangesetEnd(walletId, success = true) - val list = handler.onLoadWalletList() - assertEquals(1, list.size) - assertEquals(1, list[0].identities.size) - val identity = list[0].identities[0] + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, loser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + funding, 1, + ) + assertEquals(0, handler.onChangesetEnd(walletId, success = true)) + assertTrue(loser.contentEquals(db.txoDao().getByOutpoint(funding)!!.spendingTxid)) - assertEquals(1, identity.contacts.size) - val contact = identity.contacts[0] - assertTrue(ownerId.contentEquals(contact.ownerIdentityId)) - assertTrue(contactId.contentEquals(contact.contactIdentityId)) - assertFalse(contact.isOutgoing) - assertEquals(2, contact.senderKeyIndex) - assertEquals(3, contact.recipientKeyIndex) - assertEquals(4, contact.accountReference) - assertTrue(ByteArray(96) { 5 }.contentEquals(contact.encryptedPublicKey)) - assertTrue(ByteArray(3) { 6 }.contentEquals(contact.encryptedAccountLabel!!)) - assertNull(contact.autoAcceptProof) - assertEquals(100_000, contact.coreHeightCreatedAt) - assertEquals(1_700_000_000_000, contact.createdAtMillis) - assertTrue(contact.paymentChannelBroken) - assertEquals("ally", contact.alias) - assertEquals("a note", contact.note) - assertTrue(contact.isHidden) - assertEquals("Main wallet", contact.contactAccountLabel) - assertTrue(intArrayOf(0, 7).contentEquals(contact.acceptedAccounts)) + // The InstantSend winner is persisted next. It takes the input link, + // but its unconfirmed record deliberately does not spend-mark the coin. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, winner, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + funding, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winner) + handler.onChangesetEnd(walletId, success = true) + val winnerLinked = db.txoDao().getByOutpoint(funding)!! + assertFalse(winnerLinked.isSpent) + assertTrue(winner.contentEquals(winnerLinked.spendingTxid)) - assertEquals(1, identity.ignoredSenders.size) - assertTrue(mutedId.contentEquals(identity.ignoredSenders[0])) + // The loser was already persisted, so the sweep carries no claimed + // inputs. The winner link is now the only route to the durable hold. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loser), arrayOf(winner), emptyArray(), emptyArray(), -1, + ) + handler.onChangesetEnd(walletId, success = true) + + assertNull(db.transactionDao().getByTxid(loser)) + val held = db.txoDao().getByOutpoint(funding)!! + assertTrue("the persisted loser's winner-linked input stays held", held.isSpent) + assertTrue(winner.contentEquals(held.spendingTxid)) + assertEquals(0, held.spendingInputIndex) + assertTrue(winner.contentEquals(held.supersededByTxid)) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) + + db.close() + db = Room.databaseBuilder(androidContext, DashDatabase::class.java, databaseName) + .allowMainThreadQueries() + .build() + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + assertEquals(0, restarted.onChangesetEnd(walletId, success = true)) + + val replayed = db.txoDao().getByOutpoint(funding)!! + assertTrue( + "a reopen and funding replay must not restore the winner-consumed coin", + replayed.isSpent, + ) + assertTrue(winner.contentEquals(replayed.spendingTxid)) + assertEquals(0, replayed.spendingInputIndex) + assertTrue(winner.contentEquals(replayed.supersededByTxid)) + assertTrue(restarted.onLoadWalletList().single().utxos.isEmpty()) } @Test - fun loadWalletListScopesToTheHandlerNetwork() = runTest { - // A network-scoped handler must never hand the Rust loader a - // foreign-network row — the loader inserts unconditionally, and a - // single cross-network row aborts the whole transactional load. - val scoped = PlatformWalletPersistenceHandler( - db, - Dispatchers.Unconfined, - network = org.dashfoundation.dashsdk.Network.TESTNET, - ) + fun aClaimedInputWithNoFundingRowYetLeavesAStampedTombstone() = runTest { + // The same never-held loser, but the coin it spent has not been + // classified as ours yet. The batch's claim must survive as the + // same tombstone a staged pending row turns into, so the funding + // TXO's later arrival drains into a held coin rather than a fresh + // unspent one, and the collector can reap it at the boundary. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) val xpub = ByteArray(78) { 30 } - scoped.onPersistWalletMetadata(walletId, testnet, groupId, 0) - scoped.onPersistAccountRegistration( + handler.onPersistAccountRegistration( walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, ) - val mainnetWallet = ByteArray(32) { 9 } - val mainnet = org.dashfoundation.dashsdk.Network.MAINNET.ffiValue - scoped.onPersistWalletMetadata(mainnetWallet, mainnet, groupId, 0) - scoped.onPersistAccountRegistration( - mainnetWallet, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + val account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yUtxoAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), ) + val fundingTxid = ByteArray(32) { 64 } + val neverHeldLoser = ByteArray(32) { 65 } + val externalWinner = ByteArray(32) { 66 } + val funding = makeOutpoint(fundingTxid, 0) - val list = scoped.onLoadWalletList() - assertEquals(1, list.size) - assertTrue(walletId.contentEquals(list[0].walletId)) - } + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(neverHeldLoser), arrayOf(externalWinner), + emptyArray(), arrayOf(funding), 400, + ) + handler.onChangesetEnd(walletId, success = true) - // ── DashPay contact profiles: delta upsert / tombstone, restore ─── + val tombstone = db.documentDao().getPendingInput(funding, externalWinner) + assertNotNull("a placeholder claim is written for the unfunded input", tombstone) + assertTrue(tombstone!!.isSweptTombstone) + assertNull(tombstone.spendingTransactionTxid) + assertEquals(400, tombstone.winnerMinedHeight) - /** Persist one present contact-profile delta for [contactId] owned by [ownerId]. */ - private suspend fun persistContactProfile(ownerId: ByteArray, contactId: ByteArray) { + // Re-vouching for the same input (a re-emitted sweep) is idempotent. handler.onChangesetBegin(walletId) - handler.onPersistContactProfileDelta( - walletId = walletId, - ownerId = ownerId, - contactId = contactId, - isPresent = true, - displayName = "Bob", - bio = null, - avatarUrl = "https://x/bob.png", - avatarHash = ByteArray(32) { 23 }, - avatarHashPresent = true, - avatarFingerprint = ByteArray(8), - avatarFingerprintPresent = false, - publicMessage = "yo", - checkedAtMs = 1_700_000_111_000, + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(neverHeldLoser), arrayOf(externalWinner), + emptyArray(), arrayOf(funding), 400, ) handler.onChangesetEnd(walletId, success = true) - } + assertEquals(1, db.documentDao().getPendingInputsByOutpoint(funding).size) - @Test - fun contactProfileDeltaUpsertsPresentRow() = runTest { - // A present `IdentityEntryFFI.contact_profiles` row must land as a - // cached contact-profile row — with the avatar byte fields gated on - // their `_present` flags (all-zero is a valid hash value, so - // nullability must come from the flag, not the bytes). - val ownerId = ByteArray(32) { 22 } - val contactId = ByteArray(32) { 33 } - seedIdentity(ownerId) - persistContactProfile(ownerId, contactId) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) - val rows = db.dashpayDao().getContactProfilesByOwner(ownerId) - assertEquals(1, rows.size) - val row = rows[0] - assertEquals(testnet, row.networkRaw) - assertTrue(contactId.contentEquals(row.contactIdentityId)) - assertEquals("Bob", row.displayName) - assertNull(row.bio) - assertEquals("https://x/bob.png", row.avatarUrl) - assertTrue(ByteArray(32) { 23 }.contentEquals(row.avatarHash!!)) - assertNull(row.avatarFingerprint) - assertEquals("yo", row.publicMessage) - assertEquals(1_700_000_111_000, row.checkedAtMs) + val drained = db.txoDao().getByOutpoint(funding)!! + assertTrue("the funding output arrives already held", drained.isSpent) + assertTrue(externalWinner.contentEquals(drained.supersededByTxid)) + assertTrue(db.documentDao().getPendingInputsByOutpoint(funding).isEmpty()) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) } @Test - fun contactProfileTombstoneDeletesTheRow() = runTest { - // An `is_present == false` delta means the contact removed their - // on-chain profile: the persisted row must be DELETED. An - // upsert-only pipeline would show the stale name/avatar forever. - val ownerId = ByteArray(32) { 24 } - val contactId = ByteArray(32) { 25 } - seedIdentity(ownerId) - persistContactProfile(ownerId, contactId) - assertEquals(1, db.dashpayDao().getContactProfilesByOwner(ownerId).size) + fun aWinnersLateSpentEmitDoesNotDowngradeAStampedHold() = runTest { + // The winner's own record can reach this store only after the sweep + // and the funding TXO already did — IS-locked, not yet in a block. + // Its record pass is monotonic and merely links the spender, but + // the utxos_spent emit that rides with it resolved the in-block + // gate to false and wrote it, flipping a durable stamped hold back + // into the restore set until the winner confirmed — contradicting + // the verdict the sweep already recorded. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 56 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 57 } + val winnerTxid = ByteArray(32) { 58 } + // The doomed spend, before its funding output. handler.onChangesetBegin(walletId) - val code = handler.onPersistContactProfileDelta( - walletId = walletId, - ownerId = ownerId, - contactId = contactId, - isPresent = false, - displayName = null, - bio = null, - avatarUrl = null, - avatarHash = ByteArray(32), - avatarHashPresent = false, - avatarFingerprint = ByteArray(8), - avatarFingerprintPresent = false, - publicMessage = null, - checkedAtMs = 1_700_000_222_000, + handler.onWalletChangesetTransaction( + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + pOutpoint, 1, ) handler.onChangesetEnd(walletId, success = true) - assertEquals(0, code) - assertTrue(db.dashpayDao().getContactProfilesByOwner(ownerId).isEmpty()) - } - - @Test - fun contactProfileDeltaIsDiscardedOnChangesetRollback() = runTest { - // The delta must ride the stage() buffer like every other - // changeset write: a rolled-back round leaves no row (an eager - // write here would survive a failed Rust-side round and desync - // the mirror from the authoritative state). - val ownerId = ByteArray(32) { 28 } - val contactId = ByteArray(32) { 29 } - seedIdentity(ownerId) + // The sweep holds the claim; the funding TXO then materializes it + // as a stamped hold. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + assertTrue(db.txoDao().getByOutpoint(pOutpoint)!!.isSpent) + // The winner's own record finally arrives, IS-locked (context 1 < + // in-block), with the spent emit riding along the way a real round + // delivers both. handler.onChangesetBegin(walletId) - handler.onPersistContactProfileDelta( - walletId = walletId, - ownerId = ownerId, - contactId = contactId, - isPresent = true, - displayName = "Ghost", - bio = null, - avatarUrl = null, - avatarHash = ByteArray(32), - avatarHashPresent = false, - avatarFingerprint = ByteArray(8), - avatarFingerprintPresent = false, - publicMessage = null, - checkedAtMs = 1, + handler.onWalletChangesetTransaction( + walletId, winnerTxid, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + pOutpoint, 1, ) - // Still buffered. - assertTrue(db.dashpayDao().getContactProfilesByOwner(ownerId).isEmpty()) - handler.onChangesetEnd(walletId, success = false) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) + handler.onChangesetEnd(walletId, success = true) - assertTrue(db.dashpayDao().getContactProfilesByOwner(ownerId).isEmpty()) + val held = db.txoDao().getByOutpoint(pOutpoint)!! + assertTrue( + "the winner's own unconfirmed arrival must not downgrade the stamped hold", + held.isSpent, + ) + assertTrue(winnerTxid.contentEquals(held.supersededByTxid)) + assertTrue( + "the spender is linked all the same", + winnerTxid.contentEquals(held.spendingTxid), + ) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) } @Test - fun loadWalletListRoundTripsPaymentsAndContactProfiles() = runTest { - // Relaunch-durability for the two #3841 stores: payments (Sent - // entries + memos are NOT re-derivable from UTXOs — losing them - // here loses them forever) and the contact-profile cache (without - // it the contacts UI shows raw identity ids until the next - // profile sweep re-fetches every contact). + fun aReleaseNamingACoinASettledSpenderStillClaimsIsRefused() = runTest { + // The pruned-finalized-release defect, on this store's terms: a + // chainlocked spender F is pruned upstream to a bare txid, so a + // later loser L that pays this wallet while reusing F's input (plus + // an attacker-owned one) sweeps with F's coin wrongly named in + // `releasedOutpoints`. F's row and its `spendingTxid` link survive + // HERE, and the link guard keeps L's record pass from stealing the + // attribution — so the hold pass never detaches F's coin and + // `releaseByOutpoint` refuses it, while the coin only L claimed + // still comes free in the same batch. The restore surface is the + // restart: what `onLoadWalletList` hands back is what a relaunch + // spends from. 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) { 26 } - val contactId = ByteArray(32) { 27 } - seedIdentity(ownerId) - persistContactProfile(ownerId, contactId) - // Payments are pull-persisted (refreshDashPayPayments → DAO); the - // load path reads whatever rows the refresh landed. - db.dashpayDao().upsertPayments( - listOf( - org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity( - networkRaw = testnet, - ownerIdentityId = ownerId, - counterpartyIdentityId = contactId, - amountDuffs = 123_456, - directionRaw = 0, // Sent - statusRaw = 1, // Confirmed - txid = "aa".repeat(32), - memo = "for pizza", - ), + val account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yUtxoAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, ), ) - val list = handler.onLoadWalletList() - assertEquals(1, list.size) - val identity = list[0].identities.single() + val fundingTxid = ByteArray(32) { 60 } + val settledCoin = makeOutpoint(fundingTxid, 0) + val losersOwnCoin = makeOutpoint(fundingTxid, 1) + val attackerInput = makeOutpoint(ByteArray(32) { 61 }, 0) + val finalizedTxid = ByteArray(32) { 62 } + val loserTxid = ByteArray(32) { 63 } + val winnerTxid = ByteArray(32) { 64 } - assertEquals(1, identity.payments.size) - val payment = identity.payments[0] - assertEquals("aa".repeat(32), payment.txid) - assertTrue(contactId.contentEquals(payment.counterpartyId)) - assertEquals(123_456L, payment.amountDuffs) - assertEquals(0.toByte(), payment.directionRaw) - assertEquals(1.toByte(), payment.statusRaw) - assertEquals("for pizza", payment.memo) + // Fund both coins. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 200_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 1, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) - assertEquals(1, identity.contactProfiles.size) - val profile = identity.contactProfiles[0] - assertTrue(contactId.contentEquals(profile.contactId)) - assertEquals("Bob", profile.displayName) - assertNull(profile.bio) - assertEquals("https://x/bob.png", profile.avatarUrl) - assertTrue(ByteArray(32) { 23 }.contentEquals(profile.avatarHash!!)) - assertNull(profile.avatarFingerprint) - assertEquals("yo", profile.publicMessage) - assertEquals(1_700_000_111_000, profile.checkedAtMs) - } + // F: the chainlocked spender of `settledCoin` — upstream keeps only + // its txid from here on; this store keeps the row and the link. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, finalizedTxid, ByteArray(10) { 5 }, 3, 120, ByteArray(32) { 8 }, + 1_700_000_100, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + settledCoin, 1, + ) + handler.onChangesetEnd(walletId, success = true) + val linked = db.txoDao().getByOutpoint(settledCoin)!! + assertTrue("sanity: F's spend marked", linked.isSpent) + assertTrue("sanity: F holds the link", finalizedTxid.contentEquals(linked.spendingTxid)) - // ── Free-function encoders ──────────────────────────────────────── + // L: arrives after F's pruning — pays this wallet, reuses F's input + // alongside the attacker's and one coin of its own. Its record pass + // must NOT steal F's link. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, loserTxid, ByteArray(10) { 6 }, 0, 0, ByteArray(32), + 0, 0, "Standard", 0, 50_000, 0, false, "", 1_700_000_200, + settledCoin + attackerInput + losersOwnCoin, 3, + ) + handler.onChangesetEnd(walletId, success = true) + val guarded = db.txoDao().getByOutpoint(settledCoin)!! + assertTrue( + "a settled spender's link is not stolen by a conflicting record", + finalizedTxid.contentEquals(guarded.spendingTxid), + ) + assertTrue( + "the loser's own coin links normally", + loserTxid.contentEquals(db.txoDao().getByOutpoint(losersOwnCoin)!!.spendingTxid), + ) - @Test - fun outPointHexEncodesDisplayOrderTxidAndVout() { - val txid = ByteArray(32) { it.toByte() } // 00 01 02 … 1f (wire order) - val outpoint = makeOutpoint(txid, 5) - val hex = encodeOutPointHex(outpoint) - // Display order reverses the txid; vout appended decimal. - assertTrue(hex.endsWith(":5")) - assertTrue(hex.startsWith("1f1e1d")) // reversed leading bytes + // W (final) beats L on the attacker input alone. Upstream's release + // set — computed from live records that no longer include F — wrongly + // names F's coin alongside the loser's own. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), + arrayOf(settledCoin, losersOwnCoin), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + val settled = db.txoDao().getByOutpoint(settledCoin)!! + assertTrue( + "a released coin a settled stored spender still claims must stay spent", + settled.isSpent, + ) + assertTrue(finalizedTxid.contentEquals(settled.spendingTxid)) + val freed = db.txoDao().getByOutpoint(losersOwnCoin)!! + assertFalse("a coin only the swept loser claimed must come free", freed.isSpent) + assertNull(freed.spendingTxid) + assertEquals( + "the restore surface hands back exactly the freed coin", + 1, + handler.onLoadWalletList().single().utxos.size, + ) } @Test - fun outPointHexDecodeIsExactInverseOfEncode() { - // Round-trip a known outpoint (wire-order txid + vout) through - // encode → decode and back; the decoded 36 bytes must equal the - // original, proving `decodeOutPointHex` is the exact inverse used - // to rebuild the Rust-side outpoint from the persisted display-hex - // key. Parity with the Swift `decodeOutPointHex` round-trip. - val txid = ByteArray(32) { it.toByte() } // 00 01 … 1f wire order - val outpoint = makeOutpoint(txid, 7) - val hex = encodeOutPointHex(outpoint) - val decoded = decodeOutPointHex(hex) - assertNotNull(decoded) - assertTrue(outpoint.contentEquals(decoded!!)) - // The wire txid is recoverable as the first 32 bytes (the join key - // for the unresolved-record path). - assertTrue(txid.contentEquals(decoded.copyOfRange(0, 32))) - // Malformed inputs fail closed. - assertNull(decodeOutPointHex("not-an-outpoint")) - assertNull(decodeOutPointHex("${"ab".repeat(31)}:0")) // 62-char txid + fun aPreStampHoldStillFreesOnRedelivery() = runTest { + // The backstop for rows written before holds named their winner: a + // coin held spent with neither a spender nor a `supersededByTxid` + // stamp has nothing durable behind it, so the wallet re-delivering + // it as a UTXO — the authority on what it holds — still lifts the + // mark. Every hold written today is stamped; this pins the migration + // path for the ones already on disk. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yUtxoAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 55 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + db.transactionDao().upsert( + TransactionEntity(txid = fundingTxid, transactionData = ByteArray(0)), + ) + db.txoDao().upsert( + TxoEntity( + outpoint = pOutpoint, + vout = 0, + amount = 100_000, + address = "yUtxoAddr", + isSpent = true, + walletId = walletId, + txid = fundingTxid, + ), + ) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + assertFalse( + "a hold with nothing durable behind it frees on re-delivery", + db.txoDao().getByOutpoint(pOutpoint)!!.isSpent, + ) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) } @Test - fun loadWalletListRestoresAssetLockResumeState() = runTest { - // prior-1 regression: the JNI wallet-restore path must carry the - // persisted asset-lock resume state across a cold restart — - // tracked asset locks (ALL statuses; Rust drops Consumed itself), - // the unresolved funding-tx records for the still-Broadcast rows - // joined to their transaction, and the last-applied chainlock. - // Mirror of the Swift `buildAssetLockRestoreBuffer` / - // `buildUnresolvedAssetLockTxRecordBuffer` / `lastAppliedChainLockBytes` - // round-trips. + fun aReleasedCoinAlreadyReclaimedInTheSameRoundKeepsItsNewSpender() = runTest { + // A round can carry both a release and a later transaction that + // legitimately spends the freed coin: merging folds several events + // together, and every record is written before sweeps are processed. + // By the time the release runs the coin is claimed again, and freeing + // it would hand a spent coin back to the restore set. 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yUtxoAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 50 } + val sweptTxid = ByteArray(32) { 51 } + val winnerTxid = ByteArray(32) { 52 } + val reclaimerTxid = ByteArray(32) { 53 } + val freedCoin = makeOutpoint(fundingTxid, 1) - // Stamp the last-applied chainlock onto the wallet (bincode blob is - // opaque to Kotlin — round-tripped verbatim). - val chainLockBytes = ByteArray(48) { (it + 1).toByte() } handler.onChangesetBegin(walletId) - handler.onWalletChangesetHeader( - walletId, false, 0, false, 0, 0, 0, 0, chainLockBytes, + handler.onWalletChangesetTransaction( + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 140_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 1, 40_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, ) handler.onChangesetEnd(walletId, success = true) - // A still-Broadcast (statusRaw 1) asset lock WITH a matching - // funding transaction — the resumable + unresolved case. - val fundingTxid = ByteArray(32) { 51 } - val fundingOutpoint = makeOutpoint(fundingTxid, 0) - val fundingTxData = ByteArray(24) { 52 } + // The doomed transaction claims both coins, unconfirmed as every + // swept loser is. handler.onChangesetBegin(walletId) handler.onWalletChangesetTransaction( - walletId, fundingTxid, fundingTxData, 2, 200, ByteArray(32) { 60 }, - 1_700_000_000, 0, "Standard", 0, 90_000, 0, false, "", 1_699_999_000, - ByteArray(0), 0, // funding tx: no inputs of ours + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0) + freedCoin, 2, ) - handler.onPersistAssetLockUpsert( - walletId = walletId, - outPoint = fundingOutpoint, - transactionBytes = fundingTxData, - accountIndex = 0, - fundingType = 0, // IdentityRegistration - identityIndex = 0, + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, sweptTxid) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 1, sweptTxid) + handler.onChangesetEnd(walletId, success = true) + + // One round now carries the winner, the sweep releasing the coin the + // winner did not take, and a later transaction that already spent + // that freed coin. Records are applied first, sweeps last. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, winnerTxid, ByteArray(10) { 6 }, 2, 101, ByteArray(32) { 8 }, + 1_700_000_100, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_090, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) + handler.onWalletChangesetTransaction( + walletId, reclaimerTxid, ByteArray(10) { 7 }, 2, 102, ByteArray(32) { 9 }, + 1_700_000_200, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_150, + freedCoin, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 1, reclaimerTxid) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), arrayOf(freedCoin), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + assertNull("the swept transaction row is still gone", db.transactionDao().getByTxid(sweptTxid)) + + val reclaimed = db.txoDao().getByOutpoint(freedCoin)!! + assertTrue( + "the later spender keeps its claim", + reclaimerTxid.contentEquals(reclaimed.spendingTxid), + ) + assertTrue("so the coin stays spent", reclaimed.isSpent) + assertTrue( + "and never returns to the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + @Test + fun aLaterSweepKeepingACoinSpentOverridesAnEarlierRelease() = runTest { + // JNI delivers one call per sweep batch, in order. The first frees a + // coin, a second transaction spends it, and the second sweep removes + // that spender while freeing nothing — its own winner took the coin. + // The later answer has to win, which is what applying the calls in + // sequence gives: each one holds its losers' inputs before releasing. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yUtxoAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 70 } + val firstLoser = ByteArray(32) { 71 } + val secondLoser = ByteArray(32) { 72 } + val contested = makeOutpoint(fundingTxid, 0) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + // Both losers claim the coin; each is unconfirmed, as swept losers are. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, firstLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + contested, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, firstLoser) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, secondLoser, ByteArray(10) { 6 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_100, + contested, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, secondLoser) + handler.onChangesetEnd(walletId, success = true) + + // One round, two batches, in order. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(firstLoser), arrayOf(ByteArray(32) { 73 }), arrayOf(contested), emptyArray(), 400, + ) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoser), arrayOf(ByteArray(32) { 74 }), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + val row = db.txoDao().getByOutpoint(contested)!! + assertTrue("the later sweep kept the coin spent", row.isSpent) + assertTrue( + "so it stays out of the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + /** + * Seed the review finding's exact shape: one loser transaction shared by + * two wallets, spending one coin from each. Upstream computes each + * wallet's released set independently + * (`per_wallet_released_outpoints`), and neither wallet's own winner row + * is ever created here — matching the "the winner can pay only outside + * addresses" case the released set exists to handle. Both coins live in + * the same funding transaction purely for setup convenience; what makes + * the loser shared is that it spends a TXO owned by each wallet. + * + * Returns the funding txid and the loser txid so callers can build the + * outpoints and drive the sweep. + */ + private suspend fun seedSharedLoserAcrossTwoWallets(walletA: ByteArray, walletB: ByteArray): Pair { + handler.onPersistWalletMetadata(walletA, testnet, groupId, 0) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + // Distinct xpubs — `accountExtendedPubKeyBytes` carries a unique + // index, so two accounts sharing one would silently fail the second + // registration (`guarded` swallows the constraint violation). + handler.onPersistAccountRegistration( + walletA, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 30 }, + ) + handler.onPersistAccountRegistration( + walletB, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 31 }, + ) + val accountA = db.accountDao().observeByWallet(walletA).first().single() + val accountB = db.accountDao().observeByWallet(walletB).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yWalletA", poolTypeTag = 0, addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", accountId = accountA.id, + ), + ) + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yWalletB", poolTypeTag = 0, addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", accountId = accountB.id, + ), + ) + + val fundingTxid = ByteArray(32) { 80 } + val loserTxid = ByteArray(32) { 81 } + + // P (vout 0) — wallet A's coin. + handler.onChangesetBegin(walletA) + handler.onWalletChangesetTransaction( + walletA, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 140_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletA, fundingTxid, 0, 100_000, "yWalletA", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletA, success = true) + + // Q (vout 1) — wallet B's coin, same funding transaction. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 1, 40_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletB, success = true) + + // The shared loser: unconfirmed, spends both P and Q. + handler.onChangesetBegin(walletA) + handler.onWalletChangesetTransaction( + walletA, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0) + makeOutpoint(fundingTxid, 1), 2, + ) + handler.onWalletChangesetUtxoSpent(walletA, fundingTxid, 0, loserTxid) + handler.onWalletChangesetUtxoSpent(walletA, fundingTxid, 1, loserTxid) + handler.onChangesetEnd(walletA, success = true) + + return fundingTxid to loserTxid + } + + @Test + fun sharedLoserAppliesEachWalletsOwnReleaseSetRegardlessOfOrder_walletBThenWalletA() = runTest { + // Before the fix, whichever wallet's callback ran FIRST deleted the + // shared loser row outright, using only its own released set to + // decide every input on the row — including the other wallet's + // coin. Running wallet B (which releases nothing) first used to + // delete the row before wallet A's release of P ever landed, so + // A's later call found nothing to update and P stayed wrongly + // spent forever. This pins the fix: the row must survive until + // both wallets have weighed in, and each wallet's coin must reflect + // only that wallet's own decision. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 82 } + val p = makeOutpoint(fundingTxid, 0) + val q = makeOutpoint(fundingTxid, 1) + + // Wallet B first: its own released set names nothing, so its coin + // (Q) is held rather than freed. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletB, success = true) + + assertNotNull( + "wallet B alone must not delete a row wallet A still has a claim on", + db.transactionDao().getByTxid(loserTxid), + ) + val untouchedP = db.txoDao().getByOutpoint(p)!! + assertFalse("wallet B's callback must not touch wallet A's coin", untouchedP.isSpent) + assertTrue( + "P is still linked to the loser, untouched", + loserTxid.contentEquals(untouchedP.spendingTxid), + ) + + // Wallet A second: releases P. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(p), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + assertNull("the last wallet to run performs the delete", db.transactionDao().getByTxid(loserTxid)) + + val freedP = db.txoDao().getByOutpoint(p)!! + assertFalse("wallet A's own release must free its own coin", freedP.isSpent) + assertNull(freedP.spendingTxid) + + val heldQ = db.txoDao().getByOutpoint(q)!! + assertTrue( + "wallet B's earlier decision to hold Q must survive wallet A's callback", + heldQ.isSpent, + ) + assertNull(heldQ.spendingTxid) + } + + @Test + fun sharedLoserAppliesEachWalletsOwnReleaseSetRegardlessOfOrder_walletAThenWalletB() = runTest { + // Mirror of the ordering above: wallet A (which releases P) runs + // first this time. The fix is meant to be order-independent, so + // this must land on the exact same end state. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 92 } + val p = makeOutpoint(fundingTxid, 0) + val q = makeOutpoint(fundingTxid, 1) + + // Wallet A first: releases P. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(p), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + assertNotNull( + "wallet A alone must not delete a row wallet B still has a claim on", + db.transactionDao().getByTxid(loserTxid), + ) + val untouchedQ = db.txoDao().getByOutpoint(q)!! + assertFalse("wallet A's callback must not touch wallet B's coin", untouchedQ.isSpent) + assertTrue( + "Q is still linked to the loser, untouched", + loserTxid.contentEquals(untouchedQ.spendingTxid), + ) + + // Wallet B second: releases nothing. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletB, success = true) + + assertNull("the last wallet to run performs the delete", db.transactionDao().getByTxid(loserTxid)) + + val freedP = db.txoDao().getByOutpoint(p)!! + assertFalse("wallet A's earlier release must survive wallet B's callback", freedP.isSpent) + assertNull(freedP.spendingTxid) + + val heldQ = db.txoDao().getByOutpoint(q)!! + assertTrue("wallet B's own decision to hold its coin must stick", heldQ.isSpent) + assertNull(heldQ.spendingTxid) + } + + /** + * [seedSharedLoserAcrossTwoWallets] plus an output of the loser's own — + * phantom money, since a transaction that never confirms funded + * nothing. Driven through the ordinary [onWalletChangesetUtxoAdded] + * write path, the same as every other row in this fixture, rather than + * reaching into the DB directly. + */ + private suspend fun seedSharedLoserWithOwnOutputAcrossTwoWallets( + walletA: ByteArray, + walletB: ByteArray, + ): Pair { + val (fundingTxid, loserTxid) = seedSharedLoserAcrossTwoWallets(walletA, walletB) + handler.onChangesetBegin(walletA) + handler.onWalletChangesetUtxoAdded( + walletA, loserTxid, 2, 60_000, "yLoserChange", ByteArray(25) { 6 }, + 0, false, false, false, false, + ) + handler.onChangesetEnd(walletA, success = true) + return fundingTxid to loserTxid + } + + @Test + fun sharedLoserOutputAndCoreTxRecordAreExcludedAfterOnlyOneWalletsCallbackCommits() = runTest { + // The BLOCKING review finding: a shared loser's own output, and its + // reachability through onGetCoreTxRecord, must not survive when + // only ONE wallet's callback ever commits and the other's never + // arrives at all — a crash, a rejection, or simply never coming. + // + // commit_batch calls store() once per wallet and each commits + // independently, so before the fix wallet B alone could not delete + // a row wallet A still had an outstanding claim on (see the + // sharedLoserAppliesEachWalletsOwnReleaseSet* tests above) — and + // the OUTPUT went with the row, because deletion was the only thing + // that excluded either. If wallet A's own callback then never runs, + // that hold is permanent: the row and its phantom output stay fully + // live forever, so `onGetCoreTxRecord` keeps handing the dead + // transaction back as though it were still a candidate. + // + // Only wallet B's callback ever runs here, and it releases nothing + // — the worst case, since it gives the row no reason to be + // physically deleted at all. + val walletB = ByteArray(32) { 9 } + val (_, loserTxid) = seedSharedLoserWithOwnOutputAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 82 } + val phantomOutput = makeOutpoint(loserTxid, 2) + + // Only wallet B's callback ever runs, and it releases nothing — + // wallet A's own callback (which would release P) never arrives in + // this test at all. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletB, success = true) + + assertNotNull( + "wallet A's own claim on P is still outstanding, so the row itself survives", + db.transactionDao().getByTxid(loserTxid), + ) + assertNull( + "the loser's own output must not survive even a single committed callback, " + + "regardless of which wallet's callback that was", + db.txoDao().getByOutpoint(phantomOutput), + ) + val row = db.transactionDao().getByTxid(loserTxid)!! + assertTrue( + "any callback that reaches the sweep must flag the row, not just wallet A's own", + row.isGloballySwept, + ) + + // "Restart": a fresh handler bound to the same underlying store — + // the same pattern `addressBalanceConflictPreservesDerivationIndicesAcrossRestart` + // and the pending-key restart tests below use. Wallet A's own + // callback never happens in this test, simulating a crash or a + // rejection that stops it from ever arriving — the exact scenario + // the finding describes. + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + + assertNull( + "the phantom output must not resurrect across a restart", + db.txoDao().getByOutpoint(phantomOutput), + ) + assertNull( + "wallet A must not be able to read the swept loser back as a live transaction " + + "after a restart, even though its own callback never ran", + restarted.onGetCoreTxRecord(walletId, loserTxid), + ) + val utxosA = restarted.onLoadWalletList().first { it.walletId.contentEquals(walletId) }.utxos + assertFalse( + "the phantom output must not be handed back as a restorable UTXO", + utxosA.any { it.prevTxid.contentEquals(loserTxid) && it.vout == 2 }, + ) + } + + @Test + fun twoWalletsReleasedPendingInputsDoNotDeadlockTheRowDelete() = runTest { + // Port of the Swift regression of the same name. A shared loser + // holds one unresolved pending input per wallet, and each wallet's + // own sweep releases its own coin. Released staged rows must be + // deleted outright: left attached they read as their wallet's claim + // in `hasOtherWalletClaim`, so each callback would see the other's + // row and decline the delete, and replaying either would reach the + // same stalemate — the dead row and both pending entries stored + // forever. The global marker keeps the funds correct either way; + // this pins the storage half. + val walletB = ByteArray(32) { 8 } + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 65 } + val pA = makeOutpoint(fundingTxid, 8) + val pB = makeOutpoint(fundingTxid, 9) + val loserTxid = ByteArray(32) { 66 } + val winnerTxid = ByteArray(32) { 67 } + + // The loser's row plus one still-unfunded pending claim per wallet + // — what each wallet's own record pass would have staged. + db.transactionDao().upsert( + TransactionEntity(txid = loserTxid, transactionData = ByteArray(10) { 5 }), + ) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = pA, + inputIndex = 0, + spendingTxid = loserTxid, + spendingTransactionTxid = loserTxid, + walletId = walletId, + ), + ) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = pB, + inputIndex = 1, + spendingTxid = loserTxid, + spendingTransactionTxid = loserTxid, + walletId = walletB, + ), + ) + + // Each wallet's independently committed callback, each releasing + // only its own coin. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(pA), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), arrayOf(pB), emptyArray(), 400, + ) + handler.onChangesetEnd(walletB, success = true) + + assertNull( + "a released pending input is not a claim once its own wallet has resolved it", + db.transactionDao().getByTxid(loserTxid), + ) + assertTrue(db.documentDao().getPendingInputsByOutpoint(pA).isEmpty()) + assertTrue(db.documentDao().getPendingInputsByOutpoint(pB).isEmpty()) + } + + @Test + fun aReinstatingRecordInALaterRoundRevivesASweptTransactionAndItsOutputs() = runTest { + // Cross-round reinstatement — the BLOCKING finding this round + // fixes. The sweep and its reinstating record land in two + // SEPARATE callback rounds, with wallet B's still-outstanding + // claim keeping the shared row physically present in between, + // exactly as + // sharedLoserOutputAndCoreTxRecordAreExcludedAfterOnlyOneWalletsCallbackCommits + // above establishes on its own. Before the fix, + // onWalletChangesetTransaction bailed unconditionally on + // isGloballySwept == true, so round 2's record — upstream's newer + // word, per CoreChangeSet::merge's documented IS-lock-precedence + // sequence (swept by an IS-locked conflict, then returns + // chainlocked and sweeps that conflict in turn) — would be + // silently discarded forever, and onWalletChangesetUtxoAdded would + // keep rejecting its output on the strength of a tombstone nothing + // could ever clear. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserWithOwnOutputAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 82 } + val p = makeOutpoint(fundingTxid, 0) + val phantomOutput = makeOutpoint(loserTxid, 2) + + // Round 1: only wallet B's own sweep callback runs, releasing + // nothing. Wallet A's own claim on P is still outstanding, so the + // shared row survives physically even though the global half of + // the sweep already tombstoned it and deleted its phantom output. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletB, success = true) + + val tombstoned = db.transactionDao().getByTxid(loserTxid)!! + assertTrue("sanity: the row is tombstoned after round 1", tombstoned.isGloballySwept) + assertNull( + "sanity: the loser's own output is gone after round 1", + db.txoDao().getByOutpoint(phantomOutput), + ) + + // Round 2, a SEPARATE callback (not coalesced with round 1's + // sweep — the cross-round shape the merge-level fix in + // CoreChangeSet::merge cannot reach): the wallet returns + // chainlocked and sweeps the erstwhile winner in turn. Arrives + // here exactly like any freshly-detected transaction would — + // nothing marks it as "the reinstating one" — with its own output + // riding along in the same round, transaction before utxo per the + // JNI bridge's account ordering. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, loserTxid, ByteArray(10) { 5 }, 3, 200, ByteArray(32) { 8 }, + 1_700_000_200, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onWalletChangesetUtxoAdded( + walletId, loserTxid, 2, 60_000, "yLoserChange", ByteArray(25) { 6 }, + 200, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val reinstated = db.transactionDao().getByTxid(loserTxid)!! + assertFalse( + "a later record naming a tombstoned txid must clear the tombstone", + reinstated.isGloballySwept, + ) + assertEquals(200, reinstated.blockHeight) + + val revivedOutput = db.txoDao().getByOutpoint(phantomOutput) + assertNotNull("the reinstated transaction's own output must come back", revivedOutput) + assertEquals(60_000L, revivedOutput!!.amount) + + val reclaimedP = db.txoDao().getByOutpoint(p)!! + assertTrue( + "wallet A reclaims its input once its own record is live again", + reclaimedP.isSpent, + ) + assertTrue(loserTxid.contentEquals(reclaimedP.spendingTxid)) + + assertNotNull( + "wallet A must be able to read the reinstated transaction as live again", + handler.onGetCoreTxRecord(walletId, loserTxid), + ) + + // "Restart": a fresh handler bound to the same underlying store — + // the same pattern + // sharedLoserOutputAndCoreTxRecordAreExcludedAfterOnlyOneWalletsCallbackCommits + // above uses. The reinstatement has to be durable, not just + // visible to the handler instance that just applied it. + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + + val survived = db.transactionDao().getByTxid(loserTxid)!! + assertFalse("the reinstatement must survive a restart", survived.isGloballySwept) + assertNotNull( + "the revived output must survive a restart", + db.txoDao().getByOutpoint(phantomOutput), + ) + val survivedP = db.txoDao().getByOutpoint(p)!! + assertTrue("the reclaimed input must survive a restart", survivedP.isSpent) + assertNotNull( + "the reinstated transaction must still be readable as live after a restart", + restarted.onGetCoreTxRecord(walletId, loserTxid), + ) + } + + @Test + fun aSweepReleasingMoreOutpointsThanSqliteCanBindStillCommits() = runTest { + // The released set's size follows the input count of a transaction a + // remote sender chooses, so it is not bounded by anything this wallet + // controls. Binding it one variable per outpoint crosses the + // 999-variable ceiling API 29's framework SQLite still carries: the + // statement throws, the whole atomic round fails, and the watermark + // freezes on a loser that would be re-swept into the same failure + // after every restart. + // + // The count is far past 999 because this suite runs on the host's + // SQLite, whose own ceiling is much higher — at 1200 the pre-fix code + // passed here while still being broken on API 29. What this pins is + // therefore the property that matters, that the query arity does not + // grow with the set at all, rather than one platform's exact limit. + 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 loser = ByteArray(32) { 80 } + // Comfortably past the limit, and past the 1000-variable default of + // newer SQLite too. + val released = (0 until 40000).map { i -> + makeOutpoint(ByteArray(32) { 81 }, i) + } + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, loser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -1_000, 0, false, "", 1_700_000_000, + ByteArray(0), 0, + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + val code = handler.onWalletChangesetTransactionsSwept( + walletId, + arrayOf(loser), + arrayOf(ByteArray(32) { 82 }), + released.toTypedArray(), emptyArray(), 400, + ) + val committed = handler.onChangesetEnd(walletId, success = true) + + assertEquals("the sweep callback must not fail on a large release set", 0, code) + assertEquals(0, committed) + assertNull("and the round must actually commit", db.transactionDao().getByTxid(loser)) + } + + @Test + fun sweptTransactionRollsBackWithItsRound() = runTest { + // The deletion is staged in the same buffered transaction as every + // other write in the round, so a round that fails must not take the + // rows with it. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val txid = ByteArray(32) { 43 } + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, txid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(txid), arrayOf(ByteArray(32) { 44 }), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = false) + + assertNotNull(db.transactionDao().getByTxid(txid)) + } + + @Test + fun spendBeforeFundingReconcilesViaPendingInputAndExcludesFromRestore() = runTest { + // CORE-06, out-of-order arrival: an in-block spending tx is persisted + // BEFORE its funding TXO is known (Rust's utxos_spent slice is empty + // because the previous output wasn't classified yet). The spend must + // not be lost — `inputOutpoints` stages a pending-input row that the + // funding TXO's later upsert drains, so the consumed output is excluded + // from the restore set instead of being handed back to Rust as + // spendable. 1:1 mirror of Swift resolveInputOutpoint + upsertUtxo drain. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 41 } + val spendingTxid = ByteArray(32) { 42 } + + // Changeset 1: the in-block spending tx arrives first. Its funding TXO + // is unknown, so a pending-input row is staged (no utxos_spent fires). + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, spendingTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, + 1_700_000_200, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_100, + makeOutpoint(fundingTxid, 0), 1, // spends fundingTxid:0 (TXO unknown) + ) + handler.onChangesetEnd(walletId, success = true) + + val staged = db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)) + assertEquals(1, staged.size) + assertTrue(spendingTxid.contentEquals(staged.single().spendingTxid)) + // Funding TXO absent → nothing to restore yet. + assertEquals(0, handler.onLoadWalletList().single().utxos.size) + + // Changeset 2: the funding TXO finally lands. The drain links the spend + // (in-block → isSpent) and clears the pending row. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val txo = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) + assertNotNull(txo) + assertTrue(txo!!.isSpent) + assertTrue(spendingTxid.contentEquals(txo.spendingTxid!!)) + assertTrue( + db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)).isEmpty(), + ) + // The consumed output must NOT be handed back to Rust as spendable. + assertEquals(0, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun sweptSpendBeforeFundingSurvivesRestartAndStaysSpentWhenFunded() = runTest { + // The loser can be persisted before its own funding output ever is + // (see spendBeforeFundingReconcilesViaPendingInputAndExcludesFromRestore + // above) — the spend arrives as a `pending_inputs` row rather than a + // `TxoEntity` update. When the sweep holds that input (it's not in + // `releasedOutpoints`), there is no TXO row to mark — the only record + // of the claim is the pending row, which cascades away with the loser + // it names (`spendingTransactionTxid`'s FK) unless + // `onWalletChangesetTransactionsSwept` rescues it first. This is the + // regression the review finding described: seed the pending spend, + // sweep it, restart the store, and only then let the funding UTXO + // arrive. The coin must come back spent, attributed to the winner, + // not as a fresh unspent row. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 61 } + val sweptTxid = ByteArray(32) { 62 } + val winnerTxid = ByteArray(32) { 64 } + + // Changeset 1: the doomed spend arrives with no prior + // `onWalletChangesetUtxoAdded` for `fundingTxid:0` — the funding side + // of that outpoint has not been observed yet. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onChangesetEnd(walletId, success = true) + + assertNull( + "sanity: the funding TXO has not arrived yet", + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)), + ) + assertEquals( + 1, + db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)).size, + ) + + // Changeset 2: the sweep holds the input (not in `releasedOutpoints`), + // with nothing on hand to update. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sweptTxid), arrayOf(winnerTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + assertNull("the loser is gone", db.transactionDao().getByTxid(sweptTxid)) + + // Restart: a fresh persister loading the same on-disk store — same + // Room database, new handler, matching this suite's own restart + // idiom (e.g. addressBalanceConflictPreservesDerivationIndicesAcrossRestart above). + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + + // The funding transaction finally arrives and hands the outpoint + // back as a UTXO — the ordinary path a rescan or late block takes. + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) + assertNotNull("the funding UTXO's own upsert must still create the row", coin) + assertTrue( + "the winner's claim must survive the loser's deletion, a restart, " + + "and the funding UTXO's own arrival", + coin!!.isSpent, + ) + assertTrue(winnerTxid.contentEquals(coin.supersededByTxid)) + assertEquals(0, restarted.onLoadWalletList().single().utxos.size) + } + + @Test + fun aWinnersOwnPendingRowDoesNotEvaporateTheSweepTombstone() = runTest { + // Records precede sweeps within a round, so a wallet-relevant winner + // whose own funding side is ALSO unobserved stages an ordinary + // pending row for the same outpoint moments before the sweep + // repoints the loser's row into a tombstone. The tombstone keeps the + // loser's original, older `createdAt`, so the drain's newest-wins + // pick would select the winner's ordinary row, take the gated + // branch (`isSpent` stays false until the winner confirms — never, + // for an IS-locked unconfirmed winner), skip the `supersededByTxid` + // stamp, and delete every pending row including the tombstone: the + // durable hold evaporates and the consumed coin re-enters the + // restore set. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 91 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 92 } + val winnerTxid = ByteArray(32) { 93 } + + // Changeset 1: the doomed spend arrives before its funding output. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // The loser's pending row must be strictly older than the winner's, + // as it always is in reality — `createdAt` has millisecond + // resolution and both rows land in the same test-run instant + // otherwise. + Thread.sleep(5) + + // Changeset 2: the winner's record (IS-locked, still unconfirmed) + // and the sweep it caused, records first — the order the persist + // path guarantees inside one round. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, winnerTxid, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + pOutpoint, 1, + ) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(winnerTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + // Sanity: the coexisting pair this regression is about — the + // winner's ordinary row plus the repointed tombstone. + val rows = db.documentDao().getPendingInputsByOutpoint(pOutpoint) + assertEquals(2, rows.size) + assertEquals(1, rows.count { it.isSweptTombstone }) + + // The funding TXO finally arrives and drains both rows. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint)!! + assertTrue( + "the sweep's hold must survive the winner's own coexisting pending row", + coin.isSpent, + ) + assertTrue(winnerTxid.contentEquals(coin.supersededByTxid)) + assertTrue( + "the consumed coin must stay out of the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + @Test + fun aBatchSweepingParentAndChildDeletesTheChildsClaimOnTheParentsOutput() = runTest { + // The multi-loser batch shape upstream's descendant closure always + // produces — parent P and child C removed together — which no + // fixture here ever exercised: C spends P:0, still unfunded, so the + // claim lives as a pending row. Upstream never releases a + // loser-funded outpoint, so without a co-swept check the sweep + // tombstones the claim to the winner — and P's chainlocked + // reinstatement then re-delivers P:0 straight into the + // tombstone-outranks drain: isSpent = true, supersededByTxid = + // winner, and the recovery clear refuses stamped holds. A dead + // parent's output is nobody's coin; the claim must be deleted with + // the batch. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val parentTxid = ByteArray(32) { 101 } // P — record never persisted + val pOutpoint = makeOutpoint(parentTxid, 0) + val childTxid = ByteArray(32) { 102 } // C + val winnerTxid = ByteArray(32) { 103 } // W + + // C arrives spending the still-unfunded P:0 — parked as a pending + // claim. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, childTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_100, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + assertEquals(1, db.documentDao().getPendingInputsByOutpoint(pOutpoint).size) + + // One batch removes both; upstream excludes P:0 from the released + // set because its funder is itself a loser. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(parentTxid, childTxid), + arrayOf(winnerTxid, winnerTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + assertTrue( + "a claim on a co-swept parent's output must be deleted, not tombstoned", + db.documentDao().getPendingInputsByOutpoint(pOutpoint).isEmpty(), + ) + + // The chainlocked return: P reinstated with its output re-delivered + // must land spendable — nothing the batch left behind may hold it. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, parentTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint)!! + assertFalse( + "the reinstated parent's output must not be wedged by its dead child's claim", + coin.isSpent, + ) + assertNull(coin.supersededByTxid) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun chainedSweepBeforeFundingReleasesAnEarlierTombstoneOnASecondSweep() = runTest { + // Regression for the review finding on + // sweptSpendBeforeFundingSurvivesRestartAndStaysSpentWhenFunded above: + // that fix repoints a held-but-unfunded pending input at its sweep's + // winner and detaches it from `spendingTransactionTxid` so it + // survives the loser's cascade-delete. But a SECOND sweep of that + // winner — the sweep's staged-row fetch matches + // `spendingTransactionTxid = :txid`, which the first tombstoning + // already cleared to null — cannot find the row that way anymore. + // L spends P; W spends P and Q and sweeps L, holding the still- + // unfunded P; X spends Q and sweeps W, this time releasing P. P's + // funding TXO finally arrives and must come back spendable. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 71 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val qOutpoint = makeOutpoint(ByteArray(32) { 72 }, 0) + val firstLoserTxid = ByteArray(32) { 73 } // L + val secondLoserTxid = ByteArray(32) { 74 } // W + val finalWinnerTxid = ByteArray(32) { 75 } // X + + // L spends only P, and P's funding side has never been observed. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, firstLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_070, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // First sweep: W beats L, holding P (still unfunded). + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(firstLoserTxid), arrayOf(secondLoserTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + val tombstone = db.documentDao().getPendingInputsByOutpoint(pOutpoint).single() + assertTrue("the first sweep must tombstone the pending row", tombstone.isSweptTombstone) + assertTrue(secondLoserTxid.contentEquals(tombstone.spendingTxid)) + assertNull( + "the tombstone must have detached from the doomed loser's FK", + tombstone.spendingTransactionTxid, + ) + + // W's own record — spends P and Q — must be on hand for the second + // sweep to find, the same requirement any sweep of a wallet-relevant + // loser has. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, secondLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_071, + pOutpoint + qOutpoint, 2, + ) + handler.onChangesetEnd(walletId, success = true) + + // Second sweep: X beats W, releasing P this time. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoserTxid), arrayOf(finalWinnerTxid), arrayOf(pOutpoint), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + assertTrue( + "a released outpoint's tombstone must not survive a chained sweep", + db.documentDao().getPendingInputsByOutpoint(pOutpoint).isEmpty(), + ) + + // P's funding TXO finally arrives. + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint) + assertNotNull(coin) + assertFalse( + "the final sweep released this coin, so it must come back spendable " + + "even though an earlier sweep in the chain had tombstoned it", + coin!!.isSpent, + ) + } + + @Test + fun aReleasedCoinDropsItsDeadWinnersMarker() = runTest { + // The funding-BEFORE-release ordering of the chained scenario above: + // the funding TXO arrives between the sweep that held the coin and + // the sweep that frees it, so the tombstone drains into + // `TxoEntity.supersededByTxid` and the pending row is gone by the + // time the release runs. `releaseByOutpoint` is the only writer + // that ever clears that column — a released coin keeping its dead + // winner's marker would turn the next hold on this outpoint + // permanent, because the redelivery carry-over in + // `onWalletChangesetUtxoAdded` reads a present marker as a durable + // claim and refuses to lift `isSpent` ever again. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 96 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 97 } // L + val intermediateWinner = ByteArray(32) { 98 } // W — never recorded here + val finalWinner = ByteArray(32) { 99 } // X + + // L spends the still-unfunded P. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // First sweep: W beats L, holding P. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loserTxid), arrayOf(intermediateWinner), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + // P's funding TXO arrives NOW — the drain consumes the tombstone + // and stamps the claim onto the row itself. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val stamped = db.txoDao().getByOutpoint(pOutpoint)!! + assertTrue("sanity: the drained claim holds the coin", stamped.isSpent) + assertTrue(intermediateWinner.contentEquals(stamped.supersededByTxid)) + + // Second sweep: X beats W, and this time upstream frees P. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(intermediateWinner), arrayOf(finalWinner), arrayOf(pOutpoint), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + val freed = db.txoDao().getByOutpoint(pOutpoint)!! + assertFalse("the released coin is spendable again", freed.isSpent) + assertNull( + "and its dead winner's marker goes with the hold it carried", + freed.supersededByTxid, + ) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun chainedSweepBeforeFundingRepointsAnEarlierTombstoneToTheNewWinner() = runTest { + // The held (not released) half of the chained scenario above: the + // second sweep keeps P spent instead of releasing it, and the + // tombstone must end up attributed to the NEW winner rather than the + // intermediate one that no longer has a row. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 81 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val firstLoserTxid = ByteArray(32) { 83 } // L + val secondLoserTxid = ByteArray(32) { 84 } // W + val finalWinnerTxid = ByteArray(32) { 85 } // X + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, firstLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_080, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // First sweep: W beats L, holding P. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(firstLoserTxid), arrayOf(secondLoserTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + // W's own record, needed by the second sweep below. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, secondLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_081, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // Second sweep: X beats W, still holding the same input. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoserTxid), arrayOf(finalWinnerTxid), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + + val tombstone = db.documentDao().getPendingInputsByOutpoint(pOutpoint).single() + assertTrue(tombstone.isSweptTombstone) + assertTrue( + "the tombstone must be repointed at the FINAL winner, not the " + + "intermediate one the second sweep already removed", + finalWinnerTxid.contentEquals(tombstone.spendingTxid), + ) + + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint) + assertNotNull(coin) + assertTrue( + "the final winner's claim must survive both sweeps and the " + + "funding UTXO's own arrival", + coin!!.isSpent, + ) + assertTrue(finalWinnerTxid.contentEquals(coin.supersededByTxid)) + } + + @Test + fun sharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstones() = runTest { + // Multi-wallet continuation of the chained-before-funding scenarios + // above, confirming this handler is NOT exposed to the Swift-side + // review finding on the missing-row early return: every query that + // carries a detached tombstone forward keys on the scalar + // `spendingTxid` (no FK — see [PendingInputEntity]) and runs + // unconditionally in `onWalletChangesetTransactionsSwept`, so the + // shared winner row having already been deleted by another wallet's + // independently committed callback must change nothing about this + // wallet's own release decision reaching its tombstones. + val walletB = ByteArray(32) { 9 } + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 30 }, + ) + handler.onPersistAccountRegistration( + walletB, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 31 }, + ) + val accountA = db.accountDao().observeByWallet(walletId).first().single() + val accountB = db.accountDao().observeByWallet(walletB).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yWalletA", poolTypeTag = 0, addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", accountId = accountA.id, + ), + ) + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yWalletB", poolTypeTag = 0, addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", accountId = accountB.id, + ), + ) + + val fundingTxid = ByteArray(32) { 101 } + val pA = makeOutpoint(fundingTxid, 0) + val pB = makeOutpoint(fundingTxid, 1) + val rB = makeOutpoint(fundingTxid, 2) + val sharedLoser = ByteArray(32) { 103 } // L + val sharedWinner = ByteArray(32) { 104 } // W + val finalWinner = ByteArray(32) { 105 } // X + + // The shared loser L claims one still-unfunded coin of wallet A's + // and two of wallet B's. Its record arrives through wallet A's + // round; a pending row carries the wallet of the round that wrote + // it, so wallet B's two claims are seeded directly in the exact + // shape B's own round would have written them. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, sharedLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + pA, 1, + ) + handler.onChangesetEnd(walletId, success = true) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = pB, inputIndex = 1, spendingTxid = sharedLoser, + spendingTransactionTxid = sharedLoser, walletId = walletB, + ), + ) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = rB, inputIndex = 2, spendingTxid = sharedLoser, + spendingTransactionTxid = sharedLoser, walletId = walletB, + ), + ) + + // First sweep, one independently committed callback per wallet: W + // beats L, holding everything (nothing funded, nothing released). + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sharedLoser), arrayOf(sharedWinner), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(sharedLoser), arrayOf(sharedWinner), emptyArray(), emptyArray(), 400, + ) + handler.onChangesetEnd(walletB, success = true) + assertNull("L is gone once both wallets ran", db.transactionDao().getByTxid(sharedLoser)) + + // W's own record arrives claiming all three outpoints. Each + // `(outpoint, W)` tombstone occupies the duplicate-guard key, so no + // new pending relationship attaches to W's row — the premise that + // lets wallet A's callback below delete it. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, sharedWinner, ByteArray(10) { 6 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_091, + pA + pB + rB, 3, + ) + handler.onChangesetEnd(walletId, success = true) + + // Second sweep: X beats W. Wallet A's callback runs first, releases + // its own coin, and — finding no attached claim of any other + // wallet's — deletes the shared row. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(sharedWinner), arrayOf(finalWinner), arrayOf(pA), emptyArray(), 400, + ) + handler.onChangesetEnd(walletId, success = true) + assertNull( + "sanity: wallet A's callback deleted the shared winner row — the premise " + + "wallet B's callback below has to survive", + db.transactionDao().getByTxid(sharedWinner), + ) + + // Wallet B's callback arrives after the row is gone, releasing one + // of its two coins and holding the other. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetTransactionsSwept( + walletB, arrayOf(sharedWinner), arrayOf(finalWinner), arrayOf(rB), emptyArray(), 400, + ) + handler.onChangesetEnd(walletB, success = true) + + val heldTombstone = db.documentDao().getPendingInputsByOutpoint(pB).single() + assertTrue(heldTombstone.isSweptTombstone) + assertTrue( + "the held tombstone must follow the chain to X even though W's row was " + + "already deleted by wallet A's callback", + finalWinner.contentEquals(heldTombstone.spendingTxid), + ) + assertTrue( + "wallet B's release decision must reach its tombstone even though W's " + + "row was already deleted by wallet A's callback", + db.documentDao().getPendingInputsByOutpoint(rB).isEmpty(), + ) + + // The funding TXOs finally arrive, one round per owning wallet. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yWalletA", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletB) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 1, 40_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 2, 20_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletB, success = true) + + assertFalse( + "wallet A's released coin comes back spendable", + db.txoDao().getByOutpoint(pA)!!.isSpent, + ) + val heldCoin = db.txoDao().getByOutpoint(pB)!! + assertTrue("wallet B's held coin stays spent", heldCoin.isSpent) + assertTrue( + "the held coin must be attributed to the final winner, not the deleted W", + finalWinner.contentEquals(heldCoin.supersededByTxid), + ) + val releasedCoin = db.txoDao().getByOutpoint(rB)!! + assertFalse( + "wallet B's released coin must not resurrect spent under the obsolete winner", + releasedCoin.isSpent, + ) + assertNull(releasedCoin.supersededByTxid) + } + + @Test + fun loadWalletListRestoresCoreAddressPoolsBeyondGapWindow() = runTest { + // prior-2 regression: the persisted Core address pools must come + // back on the restore row so every restored address maps to its + // derivation path — including addresses PAST the gap-limit window + // (`DEFAULT_GAP_LIMIT` = 20) that `ManagedWalletInfo::from_wallet` + // pre-derives. Without this, a restored UTXO on an out-of-window + // address has no derivation-path mapping and the wallet cannot + // sign a core-to-core spend after a cold restart. Mirror of the + // Swift `buildCoreAddressPoolBuffer` round-trip. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + + // An external (pool tag 0) address well beyond the gap window, + // used and carrying a balance + a full derivation path + pubkey. + val pubkey = ByteArray(33) { 4 } + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFarAddr", + publicKey = pubkey, + poolTypeTag = 0, + addressIndex = 100, + derivationPath = "m/44'/1'/0'/0/100", + isUsed = true, + balance = 12_345, + accountId = account.id, + ), + ) + // A second, unused internal (pool tag 1) address — proves grouping + // by pool type emits a distinct pool for the change chain. + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yChangeAddr", + publicKey = ByteArray(0), + poolTypeTag = 1, + addressIndex = 3, + derivationPath = "m/44'/1'/0'/1/3", + isUsed = false, + accountId = account.id, + ), + ) + + val list = handler.onLoadWalletList() + assertEquals(1, list.size) + val pools = list[0].coreAddressPools + // One pool per (account, poolType) group, ascending tag order. + assertEquals(2, pools.size) + + val external = pools[0] + assertEquals(0.toByte(), external.poolTypeTag) + // The pool routes via the account tuple (xpub omitted — the loader + // ignores it on this path). + assertEquals(0.toByte(), external.account.typeTag) + assertEquals(0, external.account.index) + assertEquals(0, external.account.accountXpubBytes.size) + assertEquals(1, external.addresses.size) + val far = external.addresses[0] + assertEquals("yFarAddr", far.addressBase58) + // The out-of-window address keeps its derivation path — the whole + // point of the fix. + assertEquals("m/44'/1'/0'/0/100", far.derivationPath) + assertEquals(100, far.addressIndex) + assertTrue(far.isUsed) + assertEquals(12_345L, far.balance) + assertTrue(pubkey.contentEquals(far.publicKey)) + assertEquals(0.toByte(), far.poolTypeTag) + + val internal = pools[1] + assertEquals(1.toByte(), internal.poolTypeTag) + assertEquals(1, internal.addresses.size) + val change = internal.addresses[0] + assertEquals("yChangeAddr", change.addressBase58) + assertEquals("m/44'/1'/0'/1/3", change.derivationPath) + assertEquals(3, change.addressIndex) + assertFalse(change.isUsed) + // No pubkey persisted → empty (Rust derives has_public_key = false). + assertEquals(0, change.publicKey.size) + } + + @Test + fun loadWalletListRoundTripsIdentityKeysWithContractBounds() = runTest { + // Signing-critical restore path: a cold-started wallet must get its + // identities and public keys back exactly as persisted — keyId, + // repr(u8) discriminants, key bytes, and the (kind, id, docType) + // contract-bounds triple (kind 2 = SingleContractDocumentType). + 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 identityId = ByteArray(32) { 12 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 7 } + val boundsId = ByteArray(32) { 21 } + + handler.onChangesetBegin(walletId) + handler.onPersistIdentityKeyUpsert( + walletId = walletId, + identityId = identityId, + keyId = 4, + purpose = 1, + securityLevel = 2, + keyType = 0, + readOnly = true, + disabledAtIsSome = false, + disabledAt = 0, + publicKeyData = pubkey, + publicKeyHash = ByteArray(20), + walletIdIsSome = true, + keyWalletId = walletId, + derivationIndicesIsSome = false, + identityIndex = 0, + keyIndex = 0, + contractBoundsKind = 2, + contractBoundsId = boundsId, + contractBoundsDocumentType = "contactRequest", + ) + handler.onChangesetEnd(walletId, success = true) + + val list = handler.onLoadWalletList() + assertEquals(1, list.size) + assertEquals(1, list[0].identities.size) + val identity = list[0].identities[0] + assertTrue(identityId.contentEquals(identity.identityId)) + assertEquals(1, identity.keys.size) + val key = identity.keys[0] + assertEquals(4, key.keyId) + assertEquals(0.toByte(), key.keyType) + assertEquals(1.toByte(), key.purpose) + assertEquals(2.toByte(), key.securityLevel) + assertTrue(key.readOnly) + assertTrue(pubkey.contentEquals(key.data)) + assertEquals(2.toByte(), key.contractBoundsKind) + assertTrue(boundsId.contentEquals(key.contractBoundsId)) + assertEquals("contactRequest", key.contractBoundsDocumentType) + } + + // ── DashPay contacts: upsert metadata, ignore delta, restore ────── + + /** Persist one incoming contact row for [senderId] owned by [ownerId]. */ + private suspend fun persistIncomingContact(ownerId: ByteArray, senderId: ByteArray) { + handler.onChangesetBegin(walletId) + handler.onPersistContactUpsert( + walletId = walletId, + ownerId = ownerId, + contactId = senderId, + isOutgoing = false, + senderKeyIndex = 2, + recipientKeyIndex = 3, + accountReference = 4, + encryptedPublicKey = ByteArray(96) { 5 }, + encryptedAccountLabel = ByteArray(3) { 6 }, + autoAcceptProof = null, + coreHeightCreatedAt = 100_000, + createdAt = 1_700_000_000_000, + paymentChannelBroken = true, + alias = "ally", + note = "a note", + isHidden = true, + contactAccountLabel = "Main wallet", + acceptedAccounts = intArrayOf(0, 7), + ) + handler.onChangesetEnd(walletId, success = true) + } + + @Test + fun contactUpsertPersistsEstablishedRowMetadata() = runTest { + // The contactInfo / DIP-15 metadata block added by upstream #3841 + // must land in Room — dropping it here would wipe alias/note/hidden + // (and the broken-channel flag) on Android relative to Swift. + val ownerId = ByteArray(32) { 15 } + val senderId = ByteArray(32) { 16 } + seedIdentity(ownerId) + persistIncomingContact(ownerId, senderId) + + val rows = db.dashpayDao().getContactRequestsByOwner(ownerId) + assertEquals(1, rows.size) + val row = rows[0] + assertTrue(row.paymentChannelBroken) + assertEquals("ally", row.contactAlias) + assertEquals("a note", row.contactNote) + assertTrue(row.contactHidden) + assertEquals("Main wallet", row.contactAccountLabel) + assertTrue( + intArrayOf(0, 7).contentEquals(decodeAcceptedAccounts(row.contactAcceptedAccounts)), + ) + } + + @Test + fun contactIgnoreDeltaDropsIncomingRowAndRoundTripsIgnoredSender() = runTest { + // Ignore (isIgnored=true): the sender's incoming row goes and a + // durable ignored-sender row appears; un-ignore deletes it again. + val ownerId = ByteArray(32) { 17 } + val senderId = ByteArray(32) { 18 } + seedIdentity(ownerId) + persistIncomingContact(ownerId, senderId) + + handler.onChangesetBegin(walletId) + assertEquals(0, handler.onPersistContactIgnored(walletId, ownerId, senderId, true)) + handler.onChangesetEnd(walletId, success = true) + + assertTrue(db.dashpayDao().getContactRequestsByOwner(ownerId).isEmpty()) + val ignored = db.dashpayDao().getIgnoredSendersByOwner(ownerId) + assertEquals(1, ignored.size) + assertTrue(senderId.contentEquals(ignored[0].ignoredSenderId)) + assertEquals(testnet, ignored[0].networkRaw) + + handler.onChangesetBegin(walletId) + assertEquals(0, handler.onPersistContactIgnored(walletId, ownerId, senderId, false)) + handler.onChangesetEnd(walletId, success = true) + assertTrue(db.dashpayDao().getIgnoredSendersByOwner(ownerId).isEmpty()) + } + + @Test + fun loadWalletListRoundTripsContactsAndIgnoredSenders() = runTest { + // Relaunch-durability: the restore rows must carry the contact + // (with its metadata) and the ignored-sender id back to Rust, or + // contact metadata is wiped and ignored senders resurface after + // every cold start. + 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) { 19 } + val contactId = ByteArray(32) { 20 } + val mutedId = ByteArray(32) { 21 } + seedIdentity(ownerId) + persistIncomingContact(ownerId, contactId) + handler.onChangesetBegin(walletId) + handler.onPersistContactIgnored(walletId, ownerId, mutedId, true) + handler.onChangesetEnd(walletId, success = true) + + val list = handler.onLoadWalletList() + assertEquals(1, list.size) + assertEquals(1, list[0].identities.size) + val identity = list[0].identities[0] + + assertEquals(1, identity.contacts.size) + val contact = identity.contacts[0] + assertTrue(ownerId.contentEquals(contact.ownerIdentityId)) + assertTrue(contactId.contentEquals(contact.contactIdentityId)) + assertFalse(contact.isOutgoing) + assertEquals(2, contact.senderKeyIndex) + assertEquals(3, contact.recipientKeyIndex) + assertEquals(4, contact.accountReference) + assertTrue(ByteArray(96) { 5 }.contentEquals(contact.encryptedPublicKey)) + assertTrue(ByteArray(3) { 6 }.contentEquals(contact.encryptedAccountLabel!!)) + assertNull(contact.autoAcceptProof) + assertEquals(100_000, contact.coreHeightCreatedAt) + assertEquals(1_700_000_000_000, contact.createdAtMillis) + assertTrue(contact.paymentChannelBroken) + assertEquals("ally", contact.alias) + assertEquals("a note", contact.note) + assertTrue(contact.isHidden) + assertEquals("Main wallet", contact.contactAccountLabel) + assertTrue(intArrayOf(0, 7).contentEquals(contact.acceptedAccounts)) + + assertEquals(1, identity.ignoredSenders.size) + assertTrue(mutedId.contentEquals(identity.ignoredSenders[0])) + } + + @Test + fun loadWalletListScopesToTheHandlerNetwork() = runTest { + // A network-scoped handler must never hand the Rust loader a + // foreign-network row — the loader inserts unconditionally, and a + // single cross-network row aborts the whole transactional load. + val scoped = PlatformWalletPersistenceHandler( + db, + Dispatchers.Unconfined, + network = org.dashfoundation.dashsdk.Network.TESTNET, + ) + val xpub = ByteArray(78) { 30 } + scoped.onPersistWalletMetadata(walletId, testnet, groupId, 0) + scoped.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + val mainnetWallet = ByteArray(32) { 9 } + val mainnet = org.dashfoundation.dashsdk.Network.MAINNET.ffiValue + scoped.onPersistWalletMetadata(mainnetWallet, mainnet, groupId, 0) + scoped.onPersistAccountRegistration( + mainnetWallet, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + + val list = scoped.onLoadWalletList() + assertEquals(1, list.size) + assertTrue(walletId.contentEquals(list[0].walletId)) + } + + // ── DashPay contact profiles: delta upsert / tombstone, restore ─── + + /** Persist one present contact-profile delta for [contactId] owned by [ownerId]. */ + private suspend fun persistContactProfile(ownerId: ByteArray, contactId: ByteArray) { + handler.onChangesetBegin(walletId) + handler.onPersistContactProfileDelta( + walletId = walletId, + ownerId = ownerId, + contactId = contactId, + isPresent = true, + displayName = "Bob", + bio = null, + avatarUrl = "https://x/bob.png", + avatarHash = ByteArray(32) { 23 }, + avatarHashPresent = true, + avatarFingerprint = ByteArray(8), + avatarFingerprintPresent = false, + publicMessage = "yo", + checkedAtMs = 1_700_000_111_000, + ) + handler.onChangesetEnd(walletId, success = true) + } + + @Test + fun contactProfileDeltaUpsertsPresentRow() = runTest { + // A present `IdentityEntryFFI.contact_profiles` row must land as a + // cached contact-profile row — with the avatar byte fields gated on + // their `_present` flags (all-zero is a valid hash value, so + // nullability must come from the flag, not the bytes). + val ownerId = ByteArray(32) { 22 } + val contactId = ByteArray(32) { 33 } + seedIdentity(ownerId) + persistContactProfile(ownerId, contactId) + + val rows = db.dashpayDao().getContactProfilesByOwner(ownerId) + assertEquals(1, rows.size) + val row = rows[0] + assertEquals(testnet, row.networkRaw) + assertTrue(contactId.contentEquals(row.contactIdentityId)) + assertEquals("Bob", row.displayName) + assertNull(row.bio) + assertEquals("https://x/bob.png", row.avatarUrl) + assertTrue(ByteArray(32) { 23 }.contentEquals(row.avatarHash!!)) + assertNull(row.avatarFingerprint) + assertEquals("yo", row.publicMessage) + assertEquals(1_700_000_111_000, row.checkedAtMs) + } + + @Test + fun contactProfileTombstoneDeletesTheRow() = runTest { + // An `is_present == false` delta means the contact removed their + // on-chain profile: the persisted row must be DELETED. An + // upsert-only pipeline would show the stale name/avatar forever. + val ownerId = ByteArray(32) { 24 } + val contactId = ByteArray(32) { 25 } + seedIdentity(ownerId) + persistContactProfile(ownerId, contactId) + assertEquals(1, db.dashpayDao().getContactProfilesByOwner(ownerId).size) + + handler.onChangesetBegin(walletId) + val code = handler.onPersistContactProfileDelta( + walletId = walletId, + ownerId = ownerId, + contactId = contactId, + isPresent = false, + displayName = null, + bio = null, + avatarUrl = null, + avatarHash = ByteArray(32), + avatarHashPresent = false, + avatarFingerprint = ByteArray(8), + avatarFingerprintPresent = false, + publicMessage = null, + checkedAtMs = 1_700_000_222_000, + ) + handler.onChangesetEnd(walletId, success = true) + assertEquals(0, code) + + assertTrue(db.dashpayDao().getContactProfilesByOwner(ownerId).isEmpty()) + } + + @Test + fun contactProfileDeltaIsDiscardedOnChangesetRollback() = runTest { + // The delta must ride the stage() buffer like every other + // changeset write: a rolled-back round leaves no row (an eager + // write here would survive a failed Rust-side round and desync + // the mirror from the authoritative state). + val ownerId = ByteArray(32) { 28 } + val contactId = ByteArray(32) { 29 } + seedIdentity(ownerId) + + handler.onChangesetBegin(walletId) + handler.onPersistContactProfileDelta( + walletId = walletId, + ownerId = ownerId, + contactId = contactId, + isPresent = true, + displayName = "Ghost", + bio = null, + avatarUrl = null, + avatarHash = ByteArray(32), + avatarHashPresent = false, + avatarFingerprint = ByteArray(8), + avatarFingerprintPresent = false, + publicMessage = null, + checkedAtMs = 1, + ) + // Still buffered. + assertTrue(db.dashpayDao().getContactProfilesByOwner(ownerId).isEmpty()) + handler.onChangesetEnd(walletId, success = false) + + assertTrue(db.dashpayDao().getContactProfilesByOwner(ownerId).isEmpty()) + } + + @Test + fun loadWalletListRoundTripsPaymentsAndContactProfiles() = runTest { + // Relaunch-durability for the two #3841 stores: payments (Sent + // entries + memos are NOT re-derivable from UTXOs — losing them + // here loses them forever) and the contact-profile cache (without + // it the contacts UI shows raw identity ids until the next + // profile sweep re-fetches every contact). + 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) { 26 } + val contactId = ByteArray(32) { 27 } + seedIdentity(ownerId) + persistContactProfile(ownerId, contactId) + // Payments are pull-persisted (refreshDashPayPayments → DAO); the + // load path reads whatever rows the refresh landed. + db.dashpayDao().upsertPayments( + listOf( + org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity( + networkRaw = testnet, + ownerIdentityId = ownerId, + counterpartyIdentityId = contactId, + amountDuffs = 123_456, + directionRaw = 0, // Sent + statusRaw = 1, // Confirmed + txid = "aa".repeat(32), + memo = "for pizza", + ), + ), + ) + + val list = handler.onLoadWalletList() + assertEquals(1, list.size) + val identity = list[0].identities.single() + + assertEquals(1, identity.payments.size) + val payment = identity.payments[0] + assertEquals("aa".repeat(32), payment.txid) + assertTrue(contactId.contentEquals(payment.counterpartyId)) + assertEquals(123_456L, payment.amountDuffs) + assertEquals(0.toByte(), payment.directionRaw) + assertEquals(1.toByte(), payment.statusRaw) + assertEquals("for pizza", payment.memo) + + assertEquals(1, identity.contactProfiles.size) + val profile = identity.contactProfiles[0] + assertTrue(contactId.contentEquals(profile.contactId)) + assertEquals("Bob", profile.displayName) + assertNull(profile.bio) + assertEquals("https://x/bob.png", profile.avatarUrl) + assertTrue(ByteArray(32) { 23 }.contentEquals(profile.avatarHash!!)) + assertNull(profile.avatarFingerprint) + assertEquals("yo", profile.publicMessage) + assertEquals(1_700_000_111_000, profile.checkedAtMs) + } + + // ── Free-function encoders ──────────────────────────────────────── + + @Test + fun outPointHexEncodesDisplayOrderTxidAndVout() { + val txid = ByteArray(32) { it.toByte() } // 00 01 02 … 1f (wire order) + val outpoint = makeOutpoint(txid, 5) + val hex = encodeOutPointHex(outpoint) + // Display order reverses the txid; vout appended decimal. + assertTrue(hex.endsWith(":5")) + assertTrue(hex.startsWith("1f1e1d")) // reversed leading bytes + } + + @Test + fun outPointHexDecodeIsExactInverseOfEncode() { + // Round-trip a known outpoint (wire-order txid + vout) through + // encode → decode and back; the decoded 36 bytes must equal the + // original, proving `decodeOutPointHex` is the exact inverse used + // to rebuild the Rust-side outpoint from the persisted display-hex + // key. Parity with the Swift `decodeOutPointHex` round-trip. + val txid = ByteArray(32) { it.toByte() } // 00 01 … 1f wire order + val outpoint = makeOutpoint(txid, 7) + val hex = encodeOutPointHex(outpoint) + val decoded = decodeOutPointHex(hex) + assertNotNull(decoded) + assertTrue(outpoint.contentEquals(decoded!!)) + // The wire txid is recoverable as the first 32 bytes (the join key + // for the unresolved-record path). + assertTrue(txid.contentEquals(decoded.copyOfRange(0, 32))) + // Malformed inputs fail closed. + assertNull(decodeOutPointHex("not-an-outpoint")) + assertNull(decodeOutPointHex("${"ab".repeat(31)}:0")) // 62-char txid + } + + @Test + fun loadWalletListRestoresAssetLockResumeState() = runTest { + // prior-1 regression: the JNI wallet-restore path must carry the + // persisted asset-lock resume state across a cold restart — + // tracked asset locks (ALL statuses; Rust drops Consumed itself), + // the unresolved funding-tx records for the still-Broadcast rows + // joined to their transaction, and the last-applied chainlock. + // Mirror of the Swift `buildAssetLockRestoreBuffer` / + // `buildUnresolvedAssetLockTxRecordBuffer` / `lastAppliedChainLockBytes` + // round-trips. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val xpub = ByteArray(78) { 30 } + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + + // Stamp the last-applied chainlock onto the wallet (bincode blob is + // opaque to Kotlin — round-tripped verbatim). + val chainLockBytes = ByteArray(48) { (it + 1).toByte() } + handler.onChangesetBegin(walletId) + handler.onWalletChangesetHeader( + walletId, false, 0, false, 0, 0, 0, 0, chainLockBytes, + ) + handler.onChangesetEnd(walletId, success = true) + + // A still-Broadcast (statusRaw 1) asset lock WITH a matching + // funding transaction — the resumable + unresolved case. + val fundingTxid = ByteArray(32) { 51 } + val fundingOutpoint = makeOutpoint(fundingTxid, 0) + val fundingTxData = ByteArray(24) { 52 } + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, fundingTxid, fundingTxData, 2, 200, ByteArray(32) { 60 }, + 1_700_000_000, 0, "Standard", 0, 90_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, // funding tx: no inputs of ours + ) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = fundingOutpoint, + transactionBytes = fundingTxData, + accountIndex = 0, + fundingType = 0, // IdentityRegistration + identityIndex = 0, amountDuffs = 90_000, status = 1, // Broadcast (< 2 → resumable + unresolved) proofBytes = null, @@ -2672,225 +4839,926 @@ class PlatformWalletPersistenceHandlerTest { ) handler.onChangesetEnd(walletId, success = true) - val list = handler.onLoadWalletList() - assertEquals(1, list.size) - val entry = list[0] + val list = handler.onLoadWalletList() + assertEquals(1, list.size) + val entry = list[0] + + // ── Last-applied chainlock round-trips verbatim ────────────── + assertTrue(chainLockBytes.contentEquals(entry.lastAppliedChainLockBytes)) + + // ── Tracked asset locks: BOTH rows, including the Consumed one ─ + // (Kotlin does not filter; Rust drops Consumed at load). + val tracked = entry.trackedAssetLocks.sortedBy { it.status } + assertEquals(2, tracked.size) + + val broadcast = tracked[0] + assertEquals(1.toByte(), broadcast.status) + assertTrue(fundingOutpoint.contentEquals(broadcast.outPoint)) + assertTrue(fundingTxData.contentEquals(broadcast.transactionBytes)) + assertEquals(0, broadcast.accountIndex) + assertEquals(0.toByte(), broadcast.fundingType) + assertEquals(0, broadcast.identityIndex) + assertEquals(90_000L, broadcast.amountDuffs) + // No proof yet on a Broadcast lock → empty (Rust maps to null/0). + assertEquals(0, broadcast.proofBytes.size) + + val consumed = tracked[1] + assertEquals(4.toByte(), consumed.status) + assertTrue(consumedOutpoint.contentEquals(consumed.outPoint)) + assertEquals(1, consumed.identityIndex) + assertEquals(55_000L, consumed.amountDuffs) + assertEquals(8, consumed.proofBytes.size) + + // ── Unresolved funding-tx records: only the statusRaw < 2 row ── + // joined to its persisted transaction (the Consumed row is excluded + // by the getUnresolvedByWallet filter). + assertEquals(1, entry.unresolvedAssetLockTxRecords.size) + val rec = entry.unresolvedAssetLockTxRecords[0] + assertEquals(0, rec.accountIndex) + assertTrue(fundingTxData.contentEquals(rec.txBytes)) + assertEquals(2, rec.contextRaw) // InBlock + assertEquals(200, rec.blockHeight) + assertEquals(32, rec.blockHash.size) + assertTrue(ByteArray(32) { 60 }.contentEquals(rec.blockHash)) + assertEquals(1_700_000_000L, rec.blockTimestamp) + assertEquals(1_699_999_000L, rec.firstSeen) + } + + @Test + fun assetLockPersistRoundTrips() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 40 }, 1) + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = outpoint, + transactionBytes = ByteArray(20) { 41 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = 100_000, + status = 1, // Broadcast + proofBytes = null, + ) + handler.onChangesetEnd(walletId, success = true) + + val row = db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint)) + assertNotNull(row) + assertEquals(100_000L, row!!.amountDuffs) + assertEquals(1, row.statusRaw) + assertFalse(row.proofBytes != null) + } + + @Test + fun assetLockUpsertNeverRegressesAConsumedRow() = runTest { + // The upsert-side twin of the delete guard below, matching Swift's + // skip and SQLite's WHERE clause: Consumed is the terminal state, + // and a stale reconstruction/enrichment snapshot folded after the + // live consumption write must not regress it. + val outpoint = makeOutpoint(ByteArray(32) { 48 }, 0) + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = outpoint, + transactionBytes = ByteArray(20) { 49 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = 70_000, + status = 4, // Consumed — terminal + proofBytes = ByteArray(8) { 50 }, + ) + // The stale snapshot arrives after the consumption write. + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = outpoint, + transactionBytes = ByteArray(20) { 49 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = 70_000, + status = 1, // Broadcast — a stale pre-consumption view + proofBytes = null, + ) + handler.onChangesetEnd(walletId, success = true) + + val row = db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint)) + assertNotNull(row) + assertEquals( + "a stale non-Consumed snapshot must not regress the terminal", + 4, + row!!.statusRaw, + ) + } + + @Test + fun assetLockRemovalNeverDeletesAConsumedRow() = runTest { + // Parity with SQLite (`status != 'consumed'`) and Swift + // (`statusRaw == 4` skip): a Consumed row is deliberately retained + // for historical lookup, and neither removal producer — a + // rejected-at-broadcast Built row, or the sweep cascade for a swept + // funding tx — can legitimately name one, so a removal reaching a + // consumed row is by construction a stale write. Kotlin deleted + // unconditionally. + val liveOutpoint = makeOutpoint(ByteArray(32) { 43 }, 0) + val consumedOutpoint = makeOutpoint(ByteArray(32) { 44 }, 1) + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = liveOutpoint, + transactionBytes = ByteArray(20) { 45 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = 100_000, + status = 1, // Broadcast — a removal may take this one + proofBytes = null, + ) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = consumedOutpoint, + transactionBytes = ByteArray(20) { 46 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 1, + amountDuffs = 55_000, + status = 4, // Consumed — terminal, retained for history + proofBytes = ByteArray(8) { 47 }, + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockRemoval(walletId, liveOutpoint) + handler.onPersistAssetLockRemoval(walletId, consumedOutpoint) + handler.onChangesetEnd(walletId, success = true) + + assertNull( + "a live row is removable", + db.assetLockDao().getByOutPointHex(encodeOutPointHex(liveOutpoint)), + ) + val consumed = db.assetLockDao().getByOutPointHex(encodeOutPointHex(consumedOutpoint)) + assertNotNull("a stale removal must never take the Consumed terminal", consumed) + assertEquals(4, consumed!!.statusRaw) + } + + // ── Invitations (DIP-13) ────────────────────────────────────────── + + @Test + fun invitationPersistRoundTripsEveryField() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 50 }, 2) + handler.onChangesetBegin(walletId) + assertEquals( + 0, + handler.onPersistInvitationUpsert( + walletId = walletId, + outPoint = outpoint, + fundingIndex = 3, + amountDuffs = 3_000_000, + expiryUnix = 1_800_086_400, + createdAtSecs = 1_800_000_000, + hasInviter = true, + status = 0, // Created + ), + ) + handler.onChangesetEnd(walletId, success = true) + + val row = db.invitationDao().getByOutPointHex(encodeOutPointHex(outpoint)) + assertNotNull(row) + assertTrue(outpoint.contentEquals(row!!.rawOutPoint)) + assertTrue(walletId.contentEquals(row.walletId)) + assertEquals(3, row.fundingIndexRaw) + assertEquals(3_000_000L, row.amountDuffs) + assertEquals(1_800_086_400, row.expiryUnix) + assertEquals(1_800_000_000, row.createdAtSecs) + assertTrue(row.hasInviter) + assertEquals(0, row.statusRaw) + assertFalse(row.reclaimInFlight) + } - // ── Last-applied chainlock round-trips verbatim ────────────── - assertTrue(chainLockBytes.contentEquals(entry.lastAppliedChainLockBytes)) + @Test + fun invitationUpsertPreservesClientWrittenStatusAndMarker() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 51 }, 0) + val hex = encodeOutPointHex(outpoint) + handler.onPersistInvitationUpsert( + walletId, outpoint, + fundingIndex = 0, amountDuffs = 300_000, expiryUnix = 10, + createdAtSecs = 5, hasInviter = false, status = 0, + ) + // The app writes the terminal status + marker locally (Rust never + // emits transitions); a Rust re-emit of the same outpoint with the + // original Created status must not reset them. + db.invitationDao().setStatusAndMarker(hex, 2, true, 99L) - // ── Tracked asset locks: BOTH rows, including the Consumed one ─ - // (Kotlin does not filter; Rust drops Consumed at load). - val tracked = entry.trackedAssetLocks.sortedBy { it.status } - assertEquals(2, tracked.size) + handler.onPersistInvitationUpsert( + walletId, outpoint, + fundingIndex = 0, amountDuffs = 300_000, expiryUnix = 10, + createdAtSecs = 5, hasInviter = false, status = 0, + ) - val broadcast = tracked[0] - assertEquals(1.toByte(), broadcast.status) - assertTrue(fundingOutpoint.contentEquals(broadcast.outPoint)) - assertTrue(fundingTxData.contentEquals(broadcast.transactionBytes)) - assertEquals(0, broadcast.accountIndex) - assertEquals(0.toByte(), broadcast.fundingType) - assertEquals(0, broadcast.identityIndex) - assertEquals(90_000L, broadcast.amountDuffs) - // No proof yet on a Broadcast lock → empty (Rust maps to null/0). - assertEquals(0, broadcast.proofBytes.size) + val row = db.invitationDao().getByOutPointHex(hex) + assertEquals(2, row!!.statusRaw) + assertTrue(row.reclaimInFlight) + } - val consumed = tracked[1] - assertEquals(4.toByte(), consumed.status) - assertTrue(consumedOutpoint.contentEquals(consumed.outPoint)) - assertEquals(1, consumed.identityIndex) - assertEquals(55_000L, consumed.amountDuffs) - assertEquals(8, consumed.proofBytes.size) + @Test + fun invitationRemovalDeletesTheRowByTheSameKey() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 52 }, 7) + handler.onPersistInvitationUpsert( + walletId, outpoint, + fundingIndex = 1, amountDuffs = 300_000, expiryUnix = 1, + createdAtSecs = 1, hasInviter = false, status = 0, + ) + assertNotNull(db.invitationDao().getByOutPointHex(encodeOutPointHex(outpoint))) - // ── Unresolved funding-tx records: only the statusRaw < 2 row ── - // joined to its persisted transaction (the Consumed row is excluded - // by the getUnresolvedByWallet filter). - assertEquals(1, entry.unresolvedAssetLockTxRecords.size) - val rec = entry.unresolvedAssetLockTxRecords[0] - assertEquals(0, rec.accountIndex) - assertTrue(fundingTxData.contentEquals(rec.txBytes)) - assertEquals(2, rec.contextRaw) // InBlock - assertEquals(200, rec.blockHeight) - assertEquals(32, rec.blockHash.size) - assertTrue(ByteArray(32) { 60 }.contentEquals(rec.blockHash)) - assertEquals(1_700_000_000L, rec.blockTimestamp) - assertEquals(1_699_999_000L, rec.firstSeen) + assertEquals(0, handler.onPersistInvitationRemoval(walletId, outpoint)) + assertNull(db.invitationDao().getByOutPointHex(encodeOutPointHex(outpoint))) } @Test - fun assetLockPersistRoundTrips() = runTest { - val outpoint = makeOutpoint(ByteArray(32) { 40 }, 1) + fun invitationWriteInRolledBackRoundNeverLands() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 53 }, 0) handler.onChangesetBegin(walletId) - handler.onPersistAssetLockUpsert( + handler.onPersistInvitationUpsert( + walletId, outpoint, + fundingIndex = 0, amountDuffs = 300_000, expiryUnix = 1, + createdAtSecs = 1, hasInviter = false, status = 0, + ) + handler.onChangesetEnd(walletId, success = false) + + assertNull(db.invitationDao().getByOutPointHex(encodeOutPointHex(outpoint))) + } + + // ── Invitation funding-index pool durability ────────────────────── + + private fun poolEntry(accountTypeTag: Byte): Int = + handler.onPersistAccountAddressPoolEntry( walletId = walletId, - outPoint = outpoint, - transactionBytes = ByteArray(20) { 41 }, + accountTypeTag = accountTypeTag, + accountStandardTag = 0, accountIndex = 0, - fundingType = 0, - identityIndex = 0, - amountDuffs = 100_000, - status = 1, // Broadcast - proofBytes = null, + accountRegistrationIndex = 0, + accountKeyClass = 0, + accountUserIdentityId = ByteArray(0), + accountFriendIdentityId = ByteArray(0), + poolTypeTag = 3, // AbsentHardened + publicKey = ByteArray(33) { 9 }, + hasPublicKey = true, + addressPoolTypeTag = 3, + addressIndex = 0, + isUsed = true, + balance = 0, + addressBase58 = "yTestInvitationPoolAddress000000", + derivationPath = "m/9'/1'/5'/3'/0'", + ) + + @Test + fun invitationPoolEntryCreatesTheMissingAccountRow() = runTest { + // Wallet exists, but the IdentityInvitation account row was never + // registered (e.g. an install predating invitation support). The + // pool write is the funding-index durability record, so it must + // create the account row instead of silently skipping. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + handler.onChangesetBegin(walletId) + assertEquals(0, poolEntry(accountTypeTag = 5)) + assertEquals(0, handler.onChangesetEnd(walletId, success = true)) + + val accounts = db.accountDao().observeByWallet(walletId).first() + assertEquals(1, accounts.size) + assertEquals(5, accounts[0].accountType) + val addresses = db.coreAddressDao().observeByAccount(accounts[0].id).first() + assertEquals(1, addresses.size) + } + + @Test + fun invitationPoolEntryWithoutWalletFailsTheRound() = runTest { + // No wallet row at all: the account row cannot be created (FK), so + // the round must FAIL — a silently-skipped write here would let + // Rust broadcast a voucher whose funding index never became + // durable (the voucher-key-reuse defect class). + handler.onChangesetBegin(walletId) + assertEquals(0, poolEntry(accountTypeTag = 5)) // staged, not yet run + assertEquals(1, handler.onChangesetEnd(walletId, success = true)) + } + + @Test + fun nonInvitationPoolEntryWithMissingAccountStillSkipsSilently() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + handler.onChangesetBegin(walletId) + assertEquals(0, poolEntry(accountTypeTag = 0)) // Standard + assertEquals(0, handler.onChangesetEnd(walletId, success = true)) + + // Unchanged pre-invitation behavior: no account row conjured. + assertTrue(db.accountDao().observeByWallet(walletId).first().isEmpty()) + } + + // ── Bounded tombstone lifetime ──────────────────────────────────── + + /** One committed round: synced height + (optionally) chainlock bytes. */ + private fun headerRound( + h: PlatformWalletPersistenceHandler, + synced: Int, + chainLockBytes: ByteArray = ByteArray(84) { 9 }, + ) { + h.onChangesetBegin(walletId) + h.onWalletChangesetHeader( + walletId = walletId, + hasSyncedHeight = true, + syncedHeight = synced, + hasBalance = false, + confirmedDelta = 0, + unconfirmedDelta = 0, + immatureDelta = 0, + lockedDelta = 0, + lastAppliedChainLockBytes = chainLockBytes, + ) + h.onChangesetEnd(walletId, success = true) + } + + /** + * One committed round delivering the numeric chainlock height, the way + * the JNI bridge does — its own slot, after the header's. + */ + private fun chainLockHeightRound(h: PlatformWalletPersistenceHandler, height: Int) { + h.onChangesetBegin(walletId) + h.onWalletChangesetChainLockHeight(walletId, height) + h.onChangesetEnd(walletId, success = true) + } + + /** + * Record a loser spending [outpoint] (funding unknown), then sweep it + * in the given winner context — a mined height (default 400) leaves + * the block-context tombstone the collection tests reason about, -1 + * (an IS-locked, unmined winner) leaves the same tombstone unstamped, + * which the collector never touches. + */ + private fun seedSweptTombstone( + outpoint: ByteArray, + loser: ByteArray, + winner: ByteArray, + winnerMinedHeight: Int = 400, + ) { + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, loser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + outpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(loser), arrayOf(winner), emptyArray(), emptyArray(), winnerMinedHeight, + ) + handler.onChangesetEnd(walletId, success = true) + } + + @Test + fun aSweptTombstoneIsCollectedAtFinalityAndNotBefore() = runTest { + // The attacker-shaped row: a swept incoming payment's foreign input + // leaves a pending tombstone that never drains — no funding TXO + // ever arrives — and before the collector existed it was permanent, + // growable one row per input by repeatedly double-spending payments + // at this wallet. The collector deletes it exactly when the + // chainlock finality boundary min(chainlockHeight, syncedHeight) + // reaches the WINNER'S mined height — no observation-age margin: + // the stamp is the winner's own height, carried on the sweep event + // itself, so nothing here guesses when the winner mined. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 71 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 72 }, ByteArray(32) { 73 }, winnerMinedHeight = 400) + + val tombstone = db.documentDao().getPendingInputsByOutpoint(p).single() + assertTrue("sanity: the sweep flagged the row", tombstone.isSweptTombstone) + assertEquals( + "the tombstone is stamped with the winner's own mined height, " + + "not any observation watermark", + 400, tombstone.winnerMinedHeight, + ) + + // Chainlocks race far ahead; the filter scan is one block short of + // the winner — the boundary has not reached the spend, so the + // funding output could still be delivered by the unscanned range. + chainLockHeightRound(handler, 10_000) + headerRound(handler, 399) + assertEquals( + "boundary min(10000, 399) = 399 is below the winner's height 400 — the hold stays", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + headerRound(handler, 400) + assertTrue( + "the boundary reaching the winner's height collects the row — no margin", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + + @Test + fun aSweptTombstoneOutlivesAnySyncProgressWithoutAChainLockHeight() = runTest { + // Synced height alone is not finality — and neither is the mere + // PRESENCE of chainlock bytes on the wallet row: the bincode blob + // is opaque here, so "bytes exist" proves nothing about WHICH + // block is final (the unsound gate the review flagged). Every + // round below carries chainlock bytes; only the numeric height + // delivered by onWalletChangesetChainLockHeight supplies a + // boundary, and the moment one lands the finalized stamp collects. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + headerRound(handler, 100) + + val fundingTxid = ByteArray(32) { 74 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 75 }, ByteArray(32) { 76 }, winnerMinedHeight = 400) + + headerRound(handler, 100_000) + assertEquals( + "chainlock bytes are on record but no numeric height is — the " + + "hold outlasts any amount of synced-height progress", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + chainLockHeightRound(handler, 100_000) + assertTrue( + "the first numeric chainlock height supplies the boundary and " + + "the finalized stamp collects", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + + @Test + fun aDrainedClaimIsImmuneToTheCollector() = runTest { + // The genuine claim the tombstone exists for: its funding TXO + // arrives, the drain moves the hold onto the TXO row + // (supersededByTxid) and deletes the pending rows — so no amount of + // later sync progress may touch the materialised hold. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + headerRound(handler, 100) + + val fundingTxid = ByteArray(32) { 77 } + val p = makeOutpoint(fundingTxid, 0) + val winner = ByteArray(32) { 79 } + seedSweptTombstone(p, ByteArray(32) { 78 }, winner, winnerMinedHeight = 400) + assertEquals( + "sanity: held, undrained, stamped with the winner's height", + 400, db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, + ) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, ) handler.onChangesetEnd(walletId, success = true) - val row = db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint)) - assertNotNull(row) - assertEquals(100_000L, row!!.amountDuffs) - assertEquals(1, row.statusRaw) - assertFalse(row.proofBytes != null) + headerRound(handler, 10_000) + chainLockHeightRound(handler, 10_000) + + val coin = db.txoDao().getByOutpoint(p) + assertNotNull("the materialised claim's row survives collection", coin) + assertTrue("still held spent by the winner's claim", coin!!.isSpent) + assertTrue(winner.contentEquals(coin.supersededByTxid)) } - // ── Invitations (DIP-13) ────────────────────────────────────────── + @Test + fun aTombstoneWithoutAWinnerHeightIsNeverCollected() = runTest { + // A tombstone with a NULL stamp is never collected. The + // mempool-context sweep path writes exactly this shape — an + // IS-locked, unmined winner has no finality horizon to stamp — + // and legacy rows (the v12 → v13 migration leaves pre-existing + // tombstones NULL) read identically. With no proof of finality + // the safe reading is to hold it forever rather than guess it + // collectible. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 80 } + val p = makeOutpoint(fundingTxid, 0) + // The real writer: an IS-context sweep of a loser whose funding + // TXO never arrived. + seedSweptTombstone(p, ByteArray(32) { 81 }, ByteArray(32) { 82 }, winnerMinedHeight = -1) + + // Two rounds, not one: a back-filling collector (the rejected + // design) would stamp the row on the first round and collect it + // on the second. + chainLockHeightRound(handler, 1_000_000) + headerRound(handler, 1_000_000) + headerRound(handler, 1_000_010) + val row = db.documentDao().getPendingInputsByOutpoint(p).single() + assertNull( + "no winner height, no proof of finality — the hold outlasts any boundary", + row.winnerMinedHeight, + ) + assertTrue(row.isSweptTombstone) + } @Test - fun invitationPersistRoundTripsEveryField() = runTest { - val outpoint = makeOutpoint(ByteArray(32) { 50 }, 2) - handler.onChangesetBegin(walletId) + fun aRepointedTombstoneIsRestampedToTheLaterSweep() = runTest { + // A chained sweep that re-points a still-unfunded claim to a new + // BLOCK-CONTEXT winner also re-stamps it with THAT winner's mined + // height: the claim now belongs to a spend anchored at a later + // block, and its collection horizon moves with it. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 86 } + val p = makeOutpoint(fundingTxid, 0) + val firstLoser = ByteArray(32) { 87 } + val secondLoser = ByteArray(32) { 88 } + val finalWinner = ByteArray(32) { 89 } + seedSweptTombstone(p, firstLoser, secondLoser, winnerMinedHeight = 400) assertEquals( - 0, - handler.onPersistInvitationUpsert( - walletId = walletId, - outPoint = outpoint, - fundingIndex = 3, - amountDuffs = 3_000_000, - expiryUnix = 1_800_086_400, - createdAtSecs = 1_800_000_000, - hasInviter = true, - status = 0, // Created - ), + "sanity: stamped with the first winner's mined height", + 400, db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, + ) + + // The first winner's own record, then its sweep — mined 50 blocks + // later — the carry-forward path that re-points the earlier + // tombstone. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransaction( + walletId, secondLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_091, + p, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoser), arrayOf(finalWinner), emptyArray(), emptyArray(), 450, ) handler.onChangesetEnd(walletId, success = true) - val row = db.invitationDao().getByOutPointHex(encodeOutPointHex(outpoint)) - assertNotNull(row) - assertTrue(outpoint.contentEquals(row!!.rawOutPoint)) - assertTrue(walletId.contentEquals(row.walletId)) - assertEquals(3, row.fundingIndexRaw) - assertEquals(3_000_000L, row.amountDuffs) - assertEquals(1_800_086_400, row.expiryUnix) - assertEquals(1_800_000_000, row.createdAtSecs) - assertTrue(row.hasInviter) - assertEquals(0, row.statusRaw) - assertFalse(row.reclaimInFlight) + val rows = db.documentDao().getPendingInputsByOutpoint(p) + assertTrue("sanity: the claim survives the chained sweep", rows.isNotEmpty()) + for (row in rows) { + assertTrue(row.isSweptTombstone) + assertTrue(finalWinner.contentEquals(row.spendingTxid)) + assertEquals( + "re-pointed ⇒ re-stamped to the later WINNER'S mined height", + 450, row.winnerMinedHeight, + ) + } } @Test - fun invitationUpsertPreservesClientWrittenStatusAndMarker() = runTest { - val outpoint = makeOutpoint(ByteArray(32) { 51 }, 0) - val hex = encodeOutPointHex(outpoint) - handler.onPersistInvitationUpsert( - walletId, outpoint, - fundingIndex = 0, amountDuffs = 300_000, expiryUnix = 10, - createdAtSecs = 5, hasInviter = false, status = 0, - ) - // The app writes the terminal status + marker locally (Rust never - // emits transitions); a Rust re-emit of the same outpoint with the - // original Created status must not reset them. - db.invitationDao().setStatusAndMarker(hex, 2, true, 99L) + fun aBlockContextTombstoneOutlivesUnrelatedAdvancementBelowItsWinnersHeight() = runTest { + // The reviewer's unrelated-advancement scenario: the chainlock can + // run arbitrarily far ahead, but while the synced height sits + // below the winner's mined height the boundary has not reached the + // spend and the hold must survive — the funding output could still + // be delivered by the unscanned range. It collects the moment the + // scan catches up. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - handler.onPersistInvitationUpsert( - walletId, outpoint, - fundingIndex = 0, amountDuffs = 300_000, expiryUnix = 10, - createdAtSecs = 5, hasInviter = false, status = 0, + val fundingTxid = ByteArray(32) { 111 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 112 }, ByteArray(32) { 113 }, winnerMinedHeight = 400) + + // Chainlocks race ahead by thousands of blocks; the filter scan + // has only reached one block short of the winner. + chainLockHeightRound(handler, 10_400) + headerRound(handler, 399) + assertEquals( + "min(chainlock, synced) = 399 is below the winner's height 400 — any " + + "amount of unrelated chainlock progress must not collect the hold", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, ) - val row = db.invitationDao().getByOutPointHex(hex) - assertEquals(2, row!!.statusRaw) - assertTrue(row.reclaimInFlight) + headerRound(handler, 400) + assertTrue( + "the scan reaching the winner's height completes the boundary and collects", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) } @Test - fun invitationRemovalDeletesTheRowByTheSameKey() = runTest { - val outpoint = makeOutpoint(ByteArray(32) { 52 }, 7) - handler.onPersistInvitationUpsert( - walletId, outpoint, - fundingIndex = 1, amountDuffs = 300_000, expiryUnix = 1, - createdAtSecs = 1, hasInviter = false, status = 0, - ) - assertNotNull(db.invitationDao().getByOutPointHex(encodeOutPointHex(outpoint))) + fun aMempoolContextSweepPreservesAnUnstampedTombstone() = runTest { + // A mempool-context sweep — an InstantSend-locked winner that has + // not mined — preserves an UNSTAMPED tombstone for every + // held-but-unfunded input. Under DIP-10 the IS lock alone settles + // those inputs: upstream deletes the loser and retains them in the + // account's `spent_outpoints`, a hold with no height that no + // record survives to rebuild (the winner need not be + // wallet-relevant). The tombstone is that hold's only durable + // carrier — CORE_SWEEP_REMOVAL requires every non-released input + // to keep a durable spend claim before its funding TXO + // materializes — and it is unstamped because an IS-locked winner + // has no mining deadline, so no boundary may ever collect it. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - assertEquals(0, handler.onPersistInvitationRemoval(walletId, outpoint)) - assertNull(db.invitationDao().getByOutPointHex(encodeOutPointHex(outpoint))) + for (i in 0 until 3) { + val p = makeOutpoint(ByteArray(32) { (114 + i).toByte() }, 0) + seedSweptTombstone( + p, + ByteArray(32) { (117 + i).toByte() }, + ByteArray(32) { (120 + i).toByte() }, + winnerMinedHeight = -1, + ) + val row = db.documentDao().getPendingInputsByOutpoint(p).single() + assertTrue( + "an unmined IS-locked winner must leave a held tombstone for input #$i", + row.isSweptTombstone, + ) + assertNull("and it carries no finality stamp", row.winnerMinedHeight) + } + // Arbitrary chainlock/height advancement never collects an + // unstamped hold — two rounds, so a back-filling collector would + // be caught too. + chainLockHeightRound(handler, 1_000_000) + headerRound(handler, 1_000_000) + headerRound(handler, 1_000_010) + assertEquals( + "every unstamped hold outlasts any boundary — only funding " + + "materialization, a block-context re-stamp, or a release resolves one", + 3L, db.documentDao().countPendingInputs().first(), + ) } @Test - fun invitationWriteInRolledBackRoundNeverLands() = runTest { - val outpoint = makeOutpoint(ByteArray(32) { 53 }, 0) + fun aMempoolContextSweepStillSpendMarksAMaterialisedCoin() = runTest { + // The mempool-context sweep still spend-marks a coin that HAS + // materialised: the row carries real funding data, so holding it + // costs nothing an attacker controls, and the winner's eventual + // block delivery is the durable evidence. Only the never-funded + // tombstone is what the mempool path refuses to create. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + + val fundingTxid = ByteArray(32) { 123 } + val p = makeOutpoint(fundingTxid, 0) + val loser = ByteArray(32) { 124 } + val winner = ByteArray(32) { 125 } + handler.onChangesetBegin(walletId) - handler.onPersistInvitationUpsert( - walletId, outpoint, - fundingIndex = 0, amountDuffs = 300_000, expiryUnix = 1, - createdAtSecs = 1, hasInviter = false, status = 0, + handler.onWalletChangesetTransaction( + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 50_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, ) - handler.onChangesetEnd(walletId, success = false) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) - assertNull(db.invitationDao().getByOutPointHex(encodeOutPointHex(outpoint))) + seedSweptTombstone(p, loser, winner, winnerMinedHeight = -1) + + val coin = db.txoDao().getByOutpoint(p)!! + assertTrue( + "a materialised coin is spend-marked by the IS-locked winner", + coin.isSpent, + ) + assertTrue(winner.contentEquals(coin.supersededByTxid)) + assertTrue( + "and no pending tombstone rides alongside the real row", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) } - // ── Invitation funding-index pool durability ────────────────────── + @Test + fun aFundingOutputArrivingAfterAMempoolSweepAndRestartLandsSpent() = runTest { + // The reviewer's named regression: an IS-locked winner sweeps on + // the mempool path and never mines, the app restarts, chainlocks + // and heights advance arbitrarily, and only then is the funding + // output delivered. Under DIP-10 the IS lock already settled that + // input — upstream deleted the loser and retained the hold in the + // account's `spent_outpoints`, a set rebuilt from records on load + // that no surviving record can reconstruct. The unstamped + // tombstone is the claim's only durable carrier, so the funding + // delivery must drain INTO it and land spent: crediting the coin + // would hand coin selection an outpoint the network has provably + // consumed. + 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 account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFundAddr", + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) - private fun poolEntry(accountTypeTag: Byte): Int = - handler.onPersistAccountAddressPoolEntry( - walletId = walletId, - accountTypeTag = accountTypeTag, - accountStandardTag = 0, - accountIndex = 0, - accountRegistrationIndex = 0, - accountKeyClass = 0, - accountUserIdentityId = ByteArray(0), - accountFriendIdentityId = ByteArray(0), - poolTypeTag = 3, // AbsentHardened - publicKey = ByteArray(33) { 9 }, - hasPublicKey = true, - addressPoolTypeTag = 3, - addressIndex = 0, - isUsed = true, - balance = 0, - addressBase58 = "yTestInvitationPoolAddress000000", - derivationPath = "m/9'/1'/5'/3'/0'", + val fundingTxid = ByteArray(32) { 126 } + val p = makeOutpoint(fundingTxid, 0) + val winner = ByteArray(32) { 0x7F } + seedSweptTombstone(p, ByteArray(32) { 127 }, winner, winnerMinedHeight = -1) + val tombstone = db.documentDao().getPendingInputsByOutpoint(p).single() + assertTrue("sanity: the mempool-context sweep left a tombstone", tombstone.isSweptTombstone) + assertNull("unstamped — no finality horizon exists", tombstone.winnerMinedHeight) + + // Restart: a fresh handler bound to the same underlying store — + // this suite's restart idiom (see + // sweptSpendBeforeFundingSurvivesRestartAndStaysSpentWhenFunded). + val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + + // Arbitrary chainlock/height advancement while the winner stays + // unmined — none of it may collect the unstamped hold. + headerRound(restarted, 25_000) + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetChainLockHeight(walletId, 25_000) + restarted.onChangesetEnd(walletId, success = true) + assertEquals( + "the unstamped hold survives the restart and every boundary", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + // The funding output is finally delivered and classified: it must + // drain into the tombstone and stay spent. + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(p) + assertNotNull(coin) + assertTrue( + "an input the IS-locked winner consumed must never come back " + + "spendable — the sweep's claim outlives the restart", + coin!!.isSpent, + ) + assertTrue( + "held by the winner the sweep named", + winner.contentEquals(coin.supersededByTxid), + ) + assertTrue( + "the claim drained into the TXO row", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + assertTrue( + "a spent coin never reaches the restored UTXO set", + restarted.onLoadWalletList().single().utxos.isEmpty(), ) + } @Test - fun invitationPoolEntryCreatesTheMissingAccountRow() = runTest { - // Wallet exists, but the IdentityInvitation account row was never - // registered (e.g. an install predating invitation support). The - // pool write is the funding-index durability record, so it must - // create the account row instead of silently skipping. + fun aMempoolRepointedTombstoneKeepsItsBlockContextStamp() = runTest { + // The IS-locked half of the chained case: an unmined winner + // re-points the claim but must NOT disturb the earlier + // block-context stamp — upstream's observed-spend entry is never + // retracted by an unconfirmed conflict. Collection at the retained + // height stays sound (the funding output is mined at or below the + // FIRST spender's height regardless of who claims the coin now), + // so the row still collects at that boundary. handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val fundingTxid = ByteArray(32) { 106 } + val p = makeOutpoint(fundingTxid, 0) + val firstLoser = ByteArray(32) { 107 } + val secondLoser = ByteArray(32) { 108 } + val finalWinner = ByteArray(32) { 109 } + seedSweptTombstone(p, firstLoser, secondLoser, winnerMinedHeight = 400) + + // The first winner is evicted by an IS-locked, unmined conflict + // that also claims the unfunded input. handler.onChangesetBegin(walletId) - assertEquals(0, poolEntry(accountTypeTag = 5)) - assertEquals(0, handler.onChangesetEnd(walletId, success = true)) + handler.onWalletChangesetTransaction( + walletId, secondLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_092, + p, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoser), arrayOf(finalWinner), emptyArray(), emptyArray(), -1, + ) + handler.onChangesetEnd(walletId, success = true) - val accounts = db.accountDao().observeByWallet(walletId).first() - assertEquals(1, accounts.size) - assertEquals(5, accounts[0].accountType) - val addresses = db.coreAddressDao().observeByAccount(accounts[0].id).first() - assertEquals(1, addresses.size) + val rows = db.documentDao().getPendingInputsByOutpoint(p) + .filter { it.isSweptTombstone } + assertTrue("sanity: the tombstone survives the chained sweep", rows.isNotEmpty()) + for (row in rows) { + assertTrue( + "an unmined winner re-points the claim", + finalWinner.contentEquals(row.spendingTxid), + ) + assertEquals( + "without touching the earlier block-context stamp", + 400, row.winnerMinedHeight, + ) + } + + chainLockHeightRound(handler, 10_000) + headerRound(handler, 400) + assertTrue( + "the retained stamp still bounds the row: the funding output sits at " + + "or below the first spender's height, so the boundary reaching it " + + "proves delivery-or-never", + db.documentDao().getPendingInputsByOutpoint(p) + .none { it.isSweptTombstone }, + ) } @Test - fun invitationPoolEntryWithoutWalletFailsTheRound() = runTest { - // No wallet row at all: the account row cannot be created (FK), so - // the round must FAIL — a silently-skipped write here would let - // Rust broadcast a voucher whose funding index never became - // durable (the voucher-key-reuse defect class). + fun anUnstampedTombstoneRestampedByABlockContextSweepBecomesCollectible() = runTest { + // The other direction of the chained case: an UNSTAMPED hold + // (IS-context sweep) re-pointed by a later BLOCK-context sweep + // gains that winner's stamp — the claim now belongs to a spend + // anchored in a real block, so it enters the collectible set and + // the boundary reaching the new winner's height collects it. One + // of the three resolution channels that bound the unstamped + // population. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 115 } + val p = makeOutpoint(fundingTxid, 0) + val firstLoser = ByteArray(32) { 116 } + val secondLoser = ByteArray(32) { 118 } + val finalWinner = ByteArray(32) { 119 } + seedSweptTombstone(p, firstLoser, secondLoser, winnerMinedHeight = -1) + assertNull( + "sanity: held and unstamped", + db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, + ) + + // The IS-locked first winner is itself beaten by a mined conflict + // still claiming the unfunded input. handler.onChangesetBegin(walletId) - assertEquals(0, poolEntry(accountTypeTag = 5)) // staged, not yet run - assertEquals(1, handler.onChangesetEnd(walletId, success = true)) + handler.onWalletChangesetTransaction( + walletId, secondLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_093, + p, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetTransactionsSwept( + walletId, arrayOf(secondLoser), arrayOf(finalWinner), emptyArray(), emptyArray(), 450, + ) + handler.onChangesetEnd(walletId, success = true) + + val rows = db.documentDao().getPendingInputsByOutpoint(p) + .filter { it.isSweptTombstone } + assertTrue("sanity: the claim survives the chained sweep", rows.isNotEmpty()) + for (row in rows) { + assertEquals( + "the block-context re-point stamps the previously unstamped hold", + 450, row.winnerMinedHeight, + ) + } + + chainLockHeightRound(handler, 10_000) + headerRound(handler, 450) + assertTrue( + "once stamped, the ordinary finality boundary collects the row", + db.documentDao().getPendingInputsByOutpoint(p).none { it.isSweptTombstone }, + ) } @Test - fun nonInvitationPoolEntryWithMissingAccountStillSkipsSilently() = runTest { + fun onWalletChangesetChainLockHeightStoresMonotonicMaxOnTheWalletRow() = runTest { + // The numeric chainlock height is the finality half of the + // collection boundary, so a stale round's chainlock must never + // lower it — monotonic max, matching the SQLite store's + // `upsert_sync_state`. handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + assertNull( + "no height on record until the slot fires", + db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight, + ) - handler.onChangesetBegin(walletId) - assertEquals(0, poolEntry(accountTypeTag = 0)) // Standard - assertEquals(0, handler.onChangesetEnd(walletId, success = true)) + chainLockHeightRound(handler, 500) + assertEquals(500, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) - // Unchanged pre-invitation behavior: no account row conjured. - assertTrue(db.accountDao().observeByWallet(walletId).first().isEmpty()) + chainLockHeightRound(handler, 400) + assertEquals( + "a stale round must not lower the stored height", + 500, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight, + ) + + chainLockHeightRound(handler, 600) + assertEquals(600, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) } // ── Asset-lock spend visibility ──────────────────────────────────── 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 400fd8b87c9..d93c592caab 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -15,6 +15,21 @@ pub struct OutPointFFI { pub vout: u32, } +impl From<&dashcore::OutPoint> for OutPointFFI { + /// The one authority for `OutPoint` → FFI conversion. This value is + /// the join key sweep releases use to find additive-path rows on the + /// host side, so a byte-order drift between hand-rolled copies would + /// silently unlink them — every conversion site routes through here. + fn from(outpoint: &dashcore::OutPoint) -> Self { + let mut txid = [0u8; 32]; + txid.copy_from_slice(outpoint.txid.as_ref()); + Self { + txid, + vout: outpoint.vout, + } + } +} + /// Outpoint of a TXO that was spent, paired with the spending /// transaction's txid. Replaces the bare `OutPointFFI` on /// `AccountChangeSetFFI.utxos_spent` so the Swift persister can @@ -237,6 +252,86 @@ pub struct WalletChangeSetFFI { /// `proof.rs` can't fire until SPV re-applies a fresh CL). pub last_applied_chain_lock_bytes: *mut u8, pub last_applied_chain_lock_bytes_len: usize, + // This struct's layout is FROZEN here. It crosses the C ABI by bare + // pointer — `on_persist_wallet_changeset_fn` carries no size or version + // field — so appending anything makes the pairing of a new callback + // with an older native producer read past the end of the producer's + // allocation: the callback signature and the manager-create entry + // points are unchanged, so nothing stops that pairing, and a capability + // bit gates semantics, not memory layout — it cannot make an + // out-of-bounds read safe. The round's sweep batches, briefly appended + // here, now travel through the size-tagged + // `PersistenceCallbacksExtension` sweep callback instead (see + // `persistence.rs`), whose declared `struct_size` is exactly the proof + // of presence this struct cannot give. New per-round payloads must take + // that same route. +} + +/// One sweep: the transactions it removed, the transaction that beat them, +/// and the coins its removal actually freed. +/// +/// Delivered through `PersistenceCallbacksExtension`'s +/// `on_persist_wallet_changeset_sweeps_fn` — deliberately NOT a field on +/// [`WalletChangeSetFFI`], whose bare-pointer ABI cannot prove to a newer +/// consumer that an older producer allocated the field (see the layout note +/// there). The batches arrive in the order the wallet emitted them, and the +/// only subtractive part of a persistence round rides here: each entry +/// describes the wallet as that sweep saw it, and a later entry can keep a +/// coin spent that an earlier one freed. **A persister must apply them in +/// sequence** — folding them together lets the first answer outlive the +/// last one that is actually true. Ignoring them leaves dead rows that are +/// handed back at the next load and re-create a balance the wallet has +/// already corrected. +#[repr(C)] +pub struct SweepBatchFFI { + /// Removed transactions, raw 32-byte txids. Delete these rows and every + /// UTXO they created. + pub txids: *const [u8; 32], + pub txids_count: usize, + /// The transaction whose arrival settled the inputs. Final, and not + /// necessarily wallet-relevant — it can pay entirely to outside + /// addresses and never reach this store at all, which is why what it + /// took cannot be worked out by looking it up. + pub superseded_by: [u8; 32], + /// Of the inputs the removed transactions claimed, the ones that came + /// free. Everything else they claimed was taken by `superseded_by` and + /// stays spent — a persister holds every input of what it deletes, so + /// this is the only thing telling it which to hand back. + pub released_outpoints: *const OutPointFFI, + pub released_outpoints_count: usize, + /// Inputs the removed transactions claimed, as far as the wallet's own + /// events prove it, for a persister that never stored the removed + /// transaction. "A persister holds every input of what it deletes" + /// fails for a transaction swept before its own detection reached + /// persistence: no row was ever written, so there is nothing to walk. + /// Settle each of these exactly like an input of a row this batch + /// deletes — free it if `released_outpoints` names it, otherwise hold + /// it spent under `superseded_by` (a durable placeholder when its + /// funding output has not arrived). Usually empty; may repeat inputs of + /// a row the persister does hold, which settle idempotently. + pub claimed_inputs: *const OutPointFFI, + pub claimed_inputs_count: usize, + /// Whether `winner_mined_height` is meaningful. `false` means the sweep + /// was triggered by an InstantSend-locked winner still waiting to be + /// mined (upstream's only other trigger — an unlocked mempool arrival + /// never sweeps), and the winner has NO finality horizon: a persister + /// must still create a durable placeholder for a held-but-unfunded + /// input — under DIP-10 the lock alone settles it, and the placeholder + /// is the only claim that survives a restart — but must leave it + /// UNSTAMPED and never collect an unstamped placeholder (the winner has + /// no mining deadline, so no watermark proves its funding output + /// delivered-or-never; only funding materialisation, a later + /// block-context re-stamp, or a release resolves it). Re-pointing an + /// existing placeholder on such a sweep must keep (not clear) any + /// stamp it already carries. + pub has_winner_mined_height: bool, + /// Mined height of `superseded_by` when `has_winner_mined_height` — + /// the winner's own block, carried from the sweep event because the + /// winner may never appear anywhere else in this wallet's stream. A + /// persister stamps it onto the placeholder it writes for a + /// held-but-unfunded input, and collects that placeholder exactly when + /// `min(chainlock_height, synced_height)` reaches the stamp. + pub winner_mined_height: u32, } // --------------------------------------------------------------------------- @@ -520,6 +615,90 @@ impl WalletChangeSetFFI { } } +/// Backing storage for one [`SweepBatchFFI`]'s nested buffers. The C struct +/// borrows into it, so the caller keeps this alive for the callback window — +/// the same `(entries, storage)` discipline +/// `build_address_pools_for_callback` uses, rather than `Box::into_raw` + +/// a paired free: nothing outlives the call, so nothing needs a free path. +pub(crate) struct SweepBatchStorage { + txids: Vec<[u8; 32]>, + released: Vec, + claimed: Vec, +} + +/// Build the C mirrors of a changeset's sweep batches for the extension +/// sweep callback (`on_persist_wallet_changeset_sweeps_fn`), preserving the +/// wallet's emission order — the one property a persister cannot recover on +/// its own, since a later batch can keep a coin spent that an earlier one +/// freed. Sweeps travel wallet-scoped, not per account: the upstream events +/// are wallet-scoped, and the persister deletes by txid — the row it +/// deletes carries its own account link. +pub(crate) fn build_sweep_batches_for_callback( + cs: &platform_wallet::changeset::CoreChangeSet, +) -> (Vec, Vec) { + let storage: Vec = cs + .sweeps + .iter() + .map(|batch| SweepBatchStorage { + txids: batch + .txids + .iter() + .map(|txid| { + let mut raw = [0u8; 32]; + raw.copy_from_slice(txid.as_ref()); + raw + }) + .collect(), + released: batch + .released_outpoints + .iter() + .map(OutPointFFI::from) + .collect(), + claimed: batch.claimed_inputs.iter().map(OutPointFFI::from).collect(), + }) + .collect(); + + let batches: Vec = cs + .sweeps + .iter() + .zip(storage.iter()) + .map(|(batch, backing)| { + let mut superseded_by = [0u8; 32]; + superseded_by.copy_from_slice(batch.superseded_by.as_ref()); + SweepBatchFFI { + // `*const`, built straight from `as_ptr()`: the storage is + // borrowed immutably here, and `Vec::as_ptr` does not permit + // writes through the pointer or anything derived from it. + // Casting to `*mut` would advertise a C ABI that a callback + // could take literally, breaking Rust's aliasing rules. + txids: if backing.txids.is_empty() { + std::ptr::null() + } else { + backing.txids.as_ptr() + }, + txids_count: backing.txids.len(), + superseded_by, + released_outpoints: if backing.released.is_empty() { + std::ptr::null() + } else { + backing.released.as_ptr() + }, + released_outpoints_count: backing.released.len(), + claimed_inputs: if backing.claimed.is_empty() { + std::ptr::null() + } else { + backing.claimed.as_ptr() + }, + claimed_inputs_count: backing.claimed.len(), + has_winner_mined_height: batch.winner_mined_height.is_some(), + winner_mined_height: batch.winner_mined_height.unwrap_or(0), + } + }) + .collect(); + + (batches, storage) +} + /// Returns the account "index" the FFI surfaces in `account_index`. /// /// For variants with a natural index field (`Standard`, `CoinJoin`, @@ -926,13 +1105,8 @@ fn record_spent_outpoints_ffi( .iter() .filter_map(|d| { let input = rec.transaction.input.get(d.index as usize)?; - let mut txid = [0u8; 32]; - txid.copy_from_slice(input.previous_output.txid.as_ref()); Some(SpentOutPointFFI { - outpoint: OutPointFFI { - txid, - vout: input.previous_output.vout, - }, + outpoint: OutPointFFI::from(&input.previous_output), spending_txid, }) }) @@ -1309,14 +1483,7 @@ fn tx_record_to_ffi( tr.transaction .input .iter() - .map(|input| { - let mut prev_txid = [0u8; 32]; - prev_txid.copy_from_slice(input.previous_output.txid.as_ref()); - OutPointFFI { - txid: prev_txid, - vout: input.previous_output.vout, - } - }) + .map(|input| OutPointFFI::from(&input.previous_output)) .collect() }; let input_outpoints_count = input_outpoints_vec.len(); @@ -1555,6 +1722,65 @@ mod tests { unsafe { free_wallet_changeset_ffi(&ffi) }; } + /// A batch's claimed inputs — the only spend evidence a persister gets + /// for a loser swept before its detection was persisted — must cross + /// the boundary next to the released set, in the same outpoint shape, + /// or the mobile stores restore the consumed coin at the next load. + #[test] + fn sweep_batches_project_claimed_inputs_beside_released_ones() { + use dashcore::hashes::Hash as _; + use dashcore::{OutPoint, Txid}; + use platform_wallet::changeset::changeset::SweepBatch; + + let claimed = OutPoint::new(Txid::from_byte_array([0x61; 32]), 3); + let released = OutPoint::new(Txid::from_byte_array([0x62; 32]), 0); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![Txid::from_byte_array([0x63; 32])], + superseded_by: Txid::from_byte_array([0x64; 32]), + winner_mined_height: Some(400), + released_outpoints: vec![released], + claimed_inputs: vec![claimed], + }], + ..CoreChangeSet::default() + }; + + let (batches, _storage) = build_sweep_batches_for_callback(&cs); + assert_eq!(batches.len(), 1); + let batch = &batches[0]; + assert_eq!(batch.claimed_inputs_count, 1); + assert_eq!(batch.released_outpoints_count, 1); + let projected = unsafe { &*batch.claimed_inputs }; + assert_eq!(projected.txid, [0x61; 32]); + assert_eq!(projected.vout, 3); + let freed = unsafe { &*batch.released_outpoints }; + assert_eq!(freed.txid, [0x62; 32]); + assert_eq!(freed.vout, 0); + } + + /// An empty claimed set projects as a null pointer with a zero count, + /// never a dangling pointer into an empty buffer. + #[test] + fn sweep_batches_without_claimed_inputs_project_null() { + use dashcore::hashes::Hash as _; + use dashcore::Txid; + use platform_wallet::changeset::changeset::SweepBatch; + + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![Txid::from_byte_array([0x63; 32])], + superseded_by: Txid::from_byte_array([0x64; 32]), + winner_mined_height: None, + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..CoreChangeSet::default() + }; + let (batches, _storage) = build_sweep_batches_for_callback(&cs); + assert!(batches[0].claimed_inputs.is_null()); + assert_eq!(batches[0].claimed_inputs_count, 0); + } + /// A folded wallet-level record files the transaction row under the /// FUNDING account while carrying the sibling account's owned /// outputs (dashpay/platform#4387), and `OutputDetail` has no diff --git a/packages/rs-platform-wallet-ffi/src/derivation.rs b/packages/rs-platform-wallet-ffi/src/derivation.rs index bf34953c3ec..f82210684e1 100644 --- a/packages/rs-platform-wallet-ffi/src/derivation.rs +++ b/packages/rs-platform-wallet-ffi/src/derivation.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use dashcore::secp256k1::Secp256k1; use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use zeroize::Zeroizing; use crate::error::*; @@ -14,24 +14,7 @@ use crate::types::{FFINetwork, Network}; use crate::{check_ptr, unwrap_result_or_return}; fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Derive a 32-byte ECDSA private key at a BIP-32 derivation path from diff --git a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs index ef4ad2bf93d..038bad06e53 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs @@ -8,7 +8,7 @@ use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPu use key_wallet::dip9::{ IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, }; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use zeroize::Zeroizing; use crate::error::*; @@ -55,24 +55,7 @@ pub(crate) unsafe fn zeroize_and_free_row(row: &mut IdentityKeyPreviewFFI) { /// Parse a BIP-39 mnemonic against every supported wordlist. pub(crate) fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Resolve a wallet's BIP-39 mnemonic via a Swift-owned diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index e007a3de056..721f0910b10 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -208,15 +208,11 @@ pub unsafe extern "C" fn platform_wallet_create_invitation( let result = unwrap_option_or_return!(option); let invitation = unwrap_result_or_return!(result); - // Marshal the funding outpoint out. `Txid: AsRef<[u8]>`, matching the - // conversion convention used across this crate's changeset FFI. - let mut txid = [0u8; 32]; - txid.copy_from_slice(invitation.out_point.txid.as_ref()); + // Marshal the funding outpoint out through the crate's one conversion + // authority (`From<&OutPoint> for OutPointFFI`) — this value joins the + // same outpoint-keyed rows the sweep releases match on. unsafe { - *out_outpoint = OutPointFFI { - txid, - vout: invitation.out_point.vout, - }; + *out_outpoint = OutPointFFI::from(&invitation.out_point); } // The URI is a secret (embeds the voucher key). Do NOT log it — the error diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 4180a774462..7849334d3f5 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -9,7 +9,8 @@ use crate::event_handler::{ use crate::handle::*; use crate::persistence::{ FFIPersister, FreeTrackedMasternodesFn, LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, - PersistTrackedMasternodesFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, + PersistWalletChangesetSweepsFn, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; @@ -172,71 +173,94 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( ) } +/// Read one negotiated slot out of a size/version-tagged extension struct +/// — the single authority for the gate every reader below applies. A slot +/// is read only when the host's declared `struct_size` proves it was +/// allocated, so an extension built before the slot existed keeps its +/// earlier callbacks and simply never has the new one read — the +/// fail-closed half of the negotiation a bare-pointer callback struct +/// cannot perform itself (dashpay/platform#4406, finding 2). The version +/// check stays an exact match on purpose: the version names the field +/// ordering, and appending under it is what `struct_size` exists for. +/// +/// # Safety +/// `$extension` must point to a live extension struct of type `$ext_ty` +/// whose `struct_size` honestly describes its allocation. +macro_rules! negotiated_extension_slot { + ($extension:expr, $ext_ty:ty, $version_const:expr, $field:ident, $fn_ty:ty) => {{ + let extension: *const $ext_ty = $extension; + let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); + let version_end = std::mem::offset_of!($ext_ty, version) + std::mem::size_of::(); + let callback_end = + std::mem::offset_of!($ext_ty, $field) + std::mem::size_of::>(); + if supplied_size < version_end + || std::ptr::addr_of!((*extension).version).read() != $version_const + || supplied_size < callback_end + { + None + } else { + std::ptr::addr_of!((*extension).$field).read() + } + }}; +} + +/// Read every negotiated persistence-extension slot through +/// [`negotiated_extension_slot!`] — one gate authority, applied per slot, +/// so a host whose `struct_size` stops mid-struct keeps exactly the +/// earlier slots it allocated. unsafe fn persistence_extension_callbacks( extension: *const PersistenceCallbacksExtension, ) -> PersistenceExtensionCallbacks { - let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); - let version_end = - std::mem::offset_of!(PersistenceCallbacksExtension, version) + std::mem::size_of::(); - if supplied_size < version_end { - return PersistenceExtensionCallbacks::default(); - } - let version = std::ptr::addr_of!((*extension).version).read(); - if version != PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION { - return PersistenceExtensionCallbacks::default(); - } - - /// Read one size-gated `Option` field: present only when the - /// caller's `struct_size` proves the complete field exists. - macro_rules! gated { - ($field:ident, $callback:ty) => {{ - let end = std::mem::offset_of!(PersistenceCallbacksExtension, $field) - + std::mem::size_of::>(); - if supplied_size < end { - None - } else { - std::ptr::addr_of!((*extension).$field).read() - } - }}; + macro_rules! slot { + ($field:ident, $fn_ty:ty) => { + negotiated_extension_slot!( + extension, + PersistenceCallbacksExtension, + PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + $field, + $fn_ty + ) + }; } PersistenceExtensionCallbacks { - dpns_name_states: gated!(on_persist_dpns_name_states_fn, PersistDpnsNameStatesFn), - persist_tracked_masternodes: gated!( + dpns_name_states: slot!(on_persist_dpns_name_states_fn, PersistDpnsNameStatesFn), + persist_tracked_masternodes: slot!( on_persist_tracked_masternodes_fn, PersistTrackedMasternodesFn ), - load_tracked_masternodes: gated!(on_load_tracked_masternodes_fn, LoadTrackedMasternodesFn), - load_tracked_masternodes_free: gated!( + load_tracked_masternodes: slot!(on_load_tracked_masternodes_fn, LoadTrackedMasternodesFn), + load_tracked_masternodes_free: slot!( on_load_tracked_masternodes_free_fn, FreeTrackedMasternodesFn ), + wallet_changeset_sweeps: slot!( + on_persist_wallet_changeset_sweeps_fn, + PersistWalletChangesetSweepsFn + ), + wallet_changeset_chain_lock_height: slot!( + on_persist_wallet_changeset_chain_lock_height_fn, + PersistWalletChangesetChainLockHeightFn + ), } } unsafe fn event_extension_dpns_callback( extension: *const EventHandlerCallbacksExtension, ) -> Option { - let supplied_size = std::ptr::addr_of!((*extension).struct_size).read(); - let version_end = - std::mem::offset_of!(EventHandlerCallbacksExtension, version) + std::mem::size_of::(); - if supplied_size < version_end { - return None; - } - let version = std::ptr::addr_of!((*extension).version).read(); - if version != PLATFORM_WALLET_EVENT_CALLBACKS_EXTENSION_VERSION { - return None; - } - let callback_end = std::mem::offset_of!( + negotiated_extension_slot!( + extension, EventHandlerCallbacksExtension, - on_dpns_marketplace_sync_completed_fn - ) + std::mem::size_of::>(); - if supplied_size < callback_end { - return None; - } - std::ptr::addr_of!((*extension).on_dpns_marketplace_sync_completed_fn).read() + PLATFORM_WALLET_EVENT_CALLBACKS_EXTENSION_VERSION, + on_dpns_marketplace_sync_completed_fn, + DpnsMarketplaceSyncCompletedFn + ) } +// The C entry point's own shape: every callback table and out-param the +// hosts pass, threaded straight through. Splitting it would only move the +// same arguments behind a struct the FFI cannot express. +#[allow(clippy::too_many_arguments)] unsafe fn platform_wallet_manager_create_impl( sdk_ptr: *const c_void, persistence: *const PersistenceCallbacks, @@ -796,6 +820,48 @@ mod tests { 0 } + unsafe extern "C" fn persist_wallet_changeset_sweeps( + _context: *mut c_void, + _wallet_id: *const u8, + _sweeps: *const crate::core_wallet_types::SweepBatchFFI, + _sweeps_count: usize, + ) -> i32 { + 0 + } + + unsafe extern "C" fn persist_wallet_changeset_chain_lock_height( + _context: *mut c_void, + _wallet_id: *const u8, + _chain_lock_height: u32, + ) -> i32 { + 0 + } + + unsafe extern "C" fn persist_tracked_masternodes( + _context: *mut c_void, + _network: *const std::os::raw::c_char, + _rows: *const crate::persistence::TrackedMasternodeFFI, + _rows_count: usize, + ) -> i32 { + 0 + } + + unsafe extern "C" fn load_tracked_masternodes( + _context: *mut c_void, + _network: *const std::os::raw::c_char, + _out_rows: *mut *const crate::persistence::TrackedMasternodeFFI, + _out_count: *mut usize, + ) -> i32 { + 0 + } + + unsafe extern "C" fn load_tracked_masternodes_free( + _context: *mut c_void, + _rows: *const crate::persistence::TrackedMasternodeFFI, + _count: usize, + ) { + } + /// Exact allocation shape used by a host compiled before the tracked /// callback trio was appended to `PersistenceCallbacksExtension`. #[repr(C)] @@ -805,7 +871,6 @@ mod tests { reserved: u32, on_persist_dpns_name_states_fn: Option, } - fn persistence_callbacks() -> PersistenceCallbacks { PersistenceCallbacks { on_changeset_begin_fn: Some(begin_changeset), @@ -1118,19 +1183,25 @@ mod tests { on_persist_dpns_name_states_fn ), on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), ..Default::default() }; let unknown = PersistenceCallbacksExtension { version: PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION + 1, on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), ..Default::default() }; let read_short = unsafe { persistence_extension_callbacks(&short) }; assert!(read_short.dpns_name_states.is_none()); assert!(read_short.persist_tracked_masternodes.is_none()); + assert!(read_short.wallet_changeset_sweeps.is_none()); + assert!(read_short.wallet_changeset_chain_lock_height.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()); } /// A caller whose `struct_size` covers only the dpns field (an @@ -1160,6 +1231,104 @@ mod tests { assert!(read.persist_tracked_masternodes.is_none()); assert!(read.load_tracked_masternodes.is_none()); assert!(read.load_tracked_masternodes_free.is_none()); + assert!(read.wallet_changeset_sweeps.is_none()); + assert!(read.wallet_changeset_chain_lock_height.is_none()); + } + + /// The exact cross-version pairing the size-negotiated extension + /// exists for: a host built when the extension ended at an earlier + /// slot declares that smaller `struct_size` — bytes it filled with a + /// live callback are still bytes, so nothing but the declared size + /// distinguishes it from a current struct. Every later slot must be + /// refused, never read (reading it would be exactly the + /// past-the-allocation dereference the changeset struct could not + /// prevent), while every slot the size does prove keeps working. + /// Walks each historical boundary: DPNS-only, the tracked-masternode + /// trio, the sweeps slot, and the terminal chainlock-height slot. + #[test] + fn a_legacy_sized_extension_refuses_the_sweeps_slot_but_keeps_dpns() { + // DPNS-era host: everything after the DPNS slot is refused. + let legacy_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_tracked_masternodes_fn + ); + let legacy = PersistenceCallbacksExtension { + struct_size: legacy_size, + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + // Set in the fixture to prove the gate never LOOKS: were the + // size check wrong, the read would find a live pointer and the + // assertion below would catch it. + on_persist_tracked_masternodes_fn: Some(persist_tracked_masternodes), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), + ..Default::default() + }; + let read = unsafe { persistence_extension_callbacks(&legacy) }; + assert!(read.dpns_name_states.is_some()); + assert!(read.persist_tracked_masternodes.is_none()); + assert!(read.load_tracked_masternodes.is_none()); + assert!(read.load_tracked_masternodes_free.is_none()); + assert!(read.wallet_changeset_sweeps.is_none()); + assert!(read.wallet_changeset_chain_lock_height.is_none()); + + // A host built when the extension ended at the tracked-masternode + // trio: the trio negotiates, the sweeps and chainlock-height + // slots are refused, never read. + let masternodes_era_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_sweeps_fn + ); + let masternodes_era = PersistenceCallbacksExtension { + struct_size: masternodes_era_size, + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + on_persist_tracked_masternodes_fn: Some(persist_tracked_masternodes), + on_load_tracked_masternodes_fn: Some(load_tracked_masternodes), + on_load_tracked_masternodes_free_fn: Some(load_tracked_masternodes_free), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), + ..Default::default() + }; + let read = unsafe { persistence_extension_callbacks(&masternodes_era) }; + assert!(read.dpns_name_states.is_some()); + assert!(read.persist_tracked_masternodes.is_some()); + assert!(read.load_tracked_masternodes.is_some()); + assert!(read.load_tracked_masternodes_free.is_some()); + assert!(read.wallet_changeset_sweeps.is_none()); + assert!(read.wallet_changeset_chain_lock_height.is_none()); + + // A host built when the extension ended at the sweeps slot: sweeps + // negotiate, the chainlock-height slot is refused, never read. + let sweeps_era_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_chain_lock_height_fn + ); + let sweeps_era = PersistenceCallbacksExtension { + struct_size: sweeps_era_size, + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), + on_persist_wallet_changeset_chain_lock_height_fn: Some( + persist_wallet_changeset_chain_lock_height, + ), + ..Default::default() + }; + let read = unsafe { persistence_extension_callbacks(&sweeps_era) }; + assert!(read.dpns_name_states.is_some()); + assert!(read.wallet_changeset_sweeps.is_some()); + assert!(read.wallet_changeset_chain_lock_height.is_none()); + + let current = PersistenceCallbacksExtension { + on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), + on_persist_tracked_masternodes_fn: Some(persist_tracked_masternodes), + on_load_tracked_masternodes_fn: Some(load_tracked_masternodes), + on_load_tracked_masternodes_free_fn: Some(load_tracked_masternodes_free), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), + on_persist_wallet_changeset_chain_lock_height_fn: Some( + persist_wallet_changeset_chain_lock_height, + ), + ..Default::default() + }; + let read = unsafe { persistence_extension_callbacks(¤t) }; + assert!(read.persist_tracked_masternodes.is_some()); + assert!(read.wallet_changeset_sweeps.is_some()); + assert!(read.wallet_changeset_chain_lock_height.is_some()); } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 3b540ffbf8d..74f5ca85b05 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -44,7 +44,9 @@ use crate::contact_persistence::{ free_contact_requests_ffi, ContactIgnoredSenderFFI, ContactRequestFFI, ContactRequestRemovalFFI, }; use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; -use crate::core_wallet_types::{free_wallet_changeset_ffi, WalletChangeSetFFI}; +use crate::core_wallet_types::{ + build_sweep_batches_for_callback, free_wallet_changeset_ffi, SweepBatchFFI, WalletChangeSetFFI, +}; use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; use crate::dpns_name_state_persistence::{ build_dpns_name_state_entries, free_dpns_name_state_entries, DpnsNameStateFFI, @@ -132,6 +134,8 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 << /// `on_persist_tracked_masternodes_fn` + `on_load_tracked_masternodes_fn` /// + `on_load_tracked_masternodes_free_fn`, and the host declaring the bit. pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << 10; +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 11; +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 12; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -181,6 +185,38 @@ pub type LoadTrackedMasternodesFn = unsafe extern "C" fn( pub type FreeTrackedMasternodesFn = unsafe extern "C" fn(context: *mut c_void, rows: *const TrackedMasternodeFFI, count: usize); +/// Carries a round's sweep batches — the removals of transactions a later, +/// final transaction provably beat to an input. Fired between the same +/// begin/end pair as the round's other per-kind callbacks, immediately +/// after `on_persist_wallet_changeset_fn`, so the additive half of the +/// round (including a wallet-relevant winner's own record) is already +/// staged when the removal decides which links point at a dead +/// transaction. Batches arrive in emission order and must be applied in +/// sequence; see [`SweepBatchFFI`]. A non-zero return fails the round like +/// any other per-kind callback — a deletion silently skipped would let +/// Rust clear the sweep while the dead row survives. +pub type PersistWalletChangesetSweepsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + sweeps: *const SweepBatchFFI, + sweeps_count: usize, +) -> i32; + +/// Carries the NUMERIC block height of the round's applied chainlock — +/// the same watermark whose bincode blob rides +/// `WalletChangeSetFFI::last_applied_chain_lock_bytes`, which is opaque to +/// a non-Rust host. The height is one half of the sweep-tombstone +/// collection boundary `min(chainlock_height, synced_height)` (see +/// [`SweepBatchFFI::winner_mined_height`]); without it a host either +/// cannot collect at all or has to guess from the synced height alone, +/// which is not finality. Fired inside the round's begin/end bracket, +/// after `on_persist_wallet_changeset_fn`, only when the round advanced +/// the chainlock watermark. Monotonic-max semantics at the host: chain +/// locks only move forward, so store `max(stored, incoming)`. A non-zero +/// return fails the round like any other per-kind callback. +pub type PersistWalletChangesetChainLockHeightFn = + unsafe extern "C" fn(context: *mut c_void, wallet_id: *const u8, chain_lock_height: u32) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -244,6 +280,39 @@ pub struct PersistenceCallbacksExtension { pub on_load_tracked_masternodes_free_fn: Option< unsafe extern "C" fn(context: *mut c_void, rows: *const TrackedMasternodeFFI, count: usize), >, + /// The round's sweep batches (see [`PersistWalletChangesetSweepsFn`]). + /// Lives here rather than on [`WalletChangeSetFFI`] because that struct + /// crosses by bare pointer with no size field: appending the batches + /// there would let a newer callback dereference fields an older native + /// producer never allocated. Appended under the same version — the + /// version names the stable field ordering, and `struct_size` is what + /// proves how much of it a given host actually supplied: Rust reads + /// this slot only when the host's declared size covers it, so an older + /// extension simply never has its sweeps read rather than being + /// rejected outright (which a version bump would do, taking its DPNS + /// callback down with it). + pub on_persist_wallet_changeset_sweeps_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + sweeps: *const SweepBatchFFI, + sweeps_count: usize, + ) -> i32, + >, + /// The round's numeric chainlock height (see + /// [`PersistWalletChangesetChainLockHeightFn`]). Appended under the + /// same version for the same reason as the sweeps slot above: + /// `struct_size` proves whether a host allocated it, and a host that + /// did not simply never has it read. Purely additive — a host without + /// it keeps working, it just cannot compute the tombstone-collection + /// finality boundary and must hold its tombstones instead. + pub on_persist_wallet_changeset_chain_lock_height_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + chain_lock_height: u32, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -256,6 +325,8 @@ impl Default for PersistenceCallbacksExtension { on_persist_tracked_masternodes_fn: None, on_load_tracked_masternodes_fn: None, on_load_tracked_masternodes_free_fn: None, + on_persist_wallet_changeset_sweeps_fn: None, + on_persist_wallet_changeset_chain_lock_height_fn: None, } } } @@ -269,6 +340,8 @@ pub struct PersistenceExtensionCallbacks { pub persist_tracked_masternodes: Option, pub load_tracked_masternodes: Option, pub load_tracked_masternodes_free: Option, + pub wallet_changeset_sweeps: Option, + pub wallet_changeset_chain_lock_height: Option, } /// C callback vtable for wallet persistence. @@ -1046,6 +1119,18 @@ pub struct FFIPersister { callbacks: PersistenceCallbacks, /// Additive callbacks negotiated outside the legacy unsized vtable. dpns_name_states_callback: Option, + /// `Some` only when the host's extension `struct_size` proved the slot + /// was allocated (see `persistence_extension_sweeps_callback` in + /// `manager.rs`) — which is also what makes it a real structural + /// attestation of `CORE_SWEEP_REMOVAL`, unlike the legacy changeset + /// callback whose unchanged signature proves nothing. + wallet_changeset_sweeps_callback: Option, + /// `Some` only when the host's extension `struct_size` proved the slot + /// was allocated. Carries the numeric chainlock height a non-Rust host + /// cannot read out of the bincode blob on the changeset struct; a host + /// without it simply never collects sweep tombstones (safe — held, not + /// leaked to the unspent set). + wallet_changeset_chain_lock_height_callback: Option, /// Additive tracked-masternode persistence trio (persist / load / /// free), likewise extension-negotiated. tracked_masternodes_callbacks: PersistenceExtensionCallbacks, @@ -1112,12 +1197,46 @@ impl FFIPersister { callbacks: PersistenceCallbacks, declared_capabilities: PersistenceCapabilities, dpns_name_states_callback: Option, + ) -> Self { + Self::new_with_persistence_capabilities_and_extension_callbacks( + callbacks, + declared_capabilities, + dpns_name_states_callback, + None, + ) + } + + pub fn new_with_persistence_capabilities_and_extension_callbacks( + callbacks: PersistenceCallbacks, + declared_capabilities: PersistenceCapabilities, + dpns_name_states_callback: Option, + wallet_changeset_sweeps_callback: Option, + ) -> Self { + Self::new_with_persistence_capabilities_and_all_extension_callbacks( + callbacks, + declared_capabilities, + dpns_name_states_callback, + wallet_changeset_sweeps_callback, + None, + ) + } + + pub fn new_with_persistence_capabilities_and_all_extension_callbacks( + callbacks: PersistenceCallbacks, + declared_capabilities: PersistenceCapabilities, + dpns_name_states_callback: Option, + wallet_changeset_sweeps_callback: Option, + wallet_changeset_chain_lock_height_callback: Option< + PersistWalletChangesetChainLockHeightFn, + >, ) -> Self { Self::new_with_persistence_capabilities_and_extensions( callbacks, declared_capabilities, PersistenceExtensionCallbacks { dpns_name_states: dpns_name_states_callback, + wallet_changeset_sweeps: wallet_changeset_sweeps_callback, + wallet_changeset_chain_lock_height: wallet_changeset_chain_lock_height_callback, ..Default::default() }, ) @@ -1131,6 +1250,9 @@ impl FFIPersister { Self { callbacks, dpns_name_states_callback: extensions.dpns_name_states, + wallet_changeset_sweeps_callback: extensions.wallet_changeset_sweeps, + wallet_changeset_chain_lock_height_callback: extensions + .wallet_changeset_chain_lock_height, tracked_masternodes_callbacks: extensions, declared_capabilities, pending: RwLock::new(BTreeMap::new()), @@ -1192,6 +1314,48 @@ impl FFIPersister { if self.callbacks.on_persist_token_balances_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); } + // The dashpay-payments slot is what the sweep's Failed flip rides + // (`dashpay_payments_overlay` on the store round). A host that + // never wired it — Android deliberately keeps payment recording + // in-memory-only — must not read as payment-durable, or the + // wallet-event adapter would couple the flip to a round that + // silently drops it: the accepted-and-ignored shape the sweep + // bit's own gating exists to prevent, reproduced one channel over. + if self.callbacks.on_persist_dashpay_payments_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::DASHPAY_PAYMENTS); + } + // Sweeps travel through the size-tagged extension callback, so — + // unlike the legacy `on_persist_wallet_changeset_fn`, whose + // unchanged C signature proves nothing about what a host actually + // reads — this slot being `Some` is a genuine structural + // attestation: it exists only when the host's declared extension + // `struct_size` covered the field. The changeset callback is still + // required alongside it because a sweep only corrects state that + // callback persists; a sweeps slot with no changeset slot would + // attest removals against rows the host never writes. The bit is + // still additionally gated by `declared_capabilities` in + // `persistence_capabilities()` below, like every other bit: the + // host must attest the semantic contract, not just wire pointers. + // + // The begin/end pair and `ATOMIC_CHANGESETS` are required on top, + // and only for this bit, because moving sweeps onto their own slot + // split one logical `CoreChangeSet` across two calls. Without a + // round that commits or rolls back as a unit, the changeset call + // can make the watermark and the additive rows durable and the + // process can stop before the sweep call applies the removal — + // leaving a host that restarts past a deletion it never performed + // and reloads the dead transaction. Nothing before sweeps could + // fail this way: every core field arrived through one callback. + if self.wallet_changeset_sweeps_callback.is_some() + && self.callbacks.on_persist_wallet_changeset_fn.is_some() + && self.callbacks.on_changeset_begin_fn.is_some() + && self.callbacks.on_changeset_end_fn.is_some() + && self + .declared_capabilities + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) + { + capabilities = capabilities.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); + } #[cfg(feature = "shielded")] if self.callbacks.on_persist_shielded_viewing_keys_fn.is_some() && self.callbacks.on_load_shielded_viewing_keys_fn.is_some() @@ -1647,6 +1811,60 @@ impl PlatformWalletPersistence for FFIPersister { round_success = false; } } + + // The numeric chainlock height rides its own size-negotiated + // extension slot for the same layout reason the sweeps below do: + // the bincode blob on the changeset struct is opaque to a + // non-Rust host, and the frozen `WalletChangeSetFFI` cannot grow + // a numeric field. Fired before the sweeps so a round carrying + // both has the boundary stored before any tombstone the sweep + // writes could be measured against it. + if let Some(cl) = core_cs.last_applied_chain_lock.as_ref() { + if let Some(cb) = self.wallet_changeset_chain_lock_height_callback { + let result = + unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), cl.block_height) }; + if result != 0 { + eprintln!( + "Wallet changeset chainlock-height persistence callback returned \ + error code {}", + result + ); + round_success = false; + } + } + } + + // The round's sweeps ride their own size-negotiated extension + // callback rather than the changeset struct (see the layout note + // on `WalletChangeSetFFI`), fired immediately after it — still + // inside the same begin/end bracket — so the additive half of + // the round, a wallet-relevant winner's own record included, is + // already staged when the removal decides which links point at a + // dead transaction. A host without the slot simply never sees + // them; that is safe to leave silent here because such a host + // can never attest `CORE_SWEEP_REMOVAL`, and the core bridge + // already freezes the sync watermark for a sweep-carrying round + // against a persister without that capability. + if !core_cs.sweeps.is_empty() { + if let Some(cb) = self.wallet_changeset_sweeps_callback { + let (batches, _batch_storage) = build_sweep_batches_for_callback(core_cs); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + batches.as_ptr(), + batches.len(), + ) + }; + if result != 0 { + eprintln!( + "Wallet changeset sweeps persistence callback returned error code {}", + result + ); + round_success = false; + } + } + } } // Send identity scalar changeset — upserts and removals. @@ -6305,6 +6523,22 @@ mod tests { ) -> i32 { 0 } + unsafe extern "C" fn noop_dashpay_payments( + _ctx: *mut c_void, + _wallet_id: *const u8, + _entries: *const DashpayPaymentPersistEntryFFI, + _count: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn noop_wallet_changeset_sweeps( + _ctx: *mut c_void, + _wallet_id: *const u8, + _sweeps: *const SweepBatchFFI, + _sweeps_count: usize, + ) -> i32 { + 0 + } unsafe extern "C" fn noop_token_balances( _ctx: *mut c_void, _wallet_id: *const u8, @@ -6650,6 +6884,417 @@ mod tests { assert!(!capabilities.contains(PersistenceCapabilities::WALLET_RESTORE)); } + /// `CORE_SWEEP_REMOVAL` requires the extension's size-negotiated + /// sweeps slot, the legacy changeset callback it corrects, AND the + /// host's explicit declaration. The legacy callback alone must never + /// attest it: its C signature never changed, so an out-of-tree host + /// built before sweeps existed still has that pointer wired — the + /// extension slot is the only structural fact that distinguishes a + /// sweep-aware host, because it exists only when the host's declared + /// `struct_size` proved it. + /// `DASHPAY_PAYMENTS` requires the payments slot AND the declaration — + /// the flip channel's mirror of the sweep bit's gating. Android's + /// vtable leaves `on_persist_dashpay_payments_fn` unset, so even a + /// host blindly OR-ing the bit must read as payments-blind: the + /// wallet-event adapter keys the sweep's Failed-flip staging on this + /// bit, and an accepted-and-dropped overlay is exactly the shape the + /// gating exists to prevent. + #[test] + fn dashpay_payments_requires_the_slot_and_the_declaration() { + fn persister_with( + callbacks: PersistenceCallbacks, + declared: PersistenceCapabilities, + ) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities(callbacks, declared) + } + // Declared but slot unwired (the Android shape): absent. + assert!(!persister_with( + PersistenceCallbacks::default(), + PersistenceCapabilities::DASHPAY_PAYMENTS + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::DASHPAY_PAYMENTS)); + + // Slot wired but never declared: absent. + assert!(!persister_with( + PersistenceCallbacks { + on_persist_dashpay_payments_fn: Some(noop_dashpay_payments), + ..Default::default() + }, + PersistenceCapabilities::NONE + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::DASHPAY_PAYMENTS)); + + // Wired and declared: attested. + assert!(persister_with( + PersistenceCallbacks { + on_persist_dashpay_payments_fn: Some(noop_dashpay_payments), + ..Default::default() + }, + PersistenceCapabilities::DASHPAY_PAYMENTS + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::DASHPAY_PAYMENTS)); + } + + #[test] + fn core_sweep_removal_requires_the_extension_slot_and_the_declaration() { + fn persister_with( + callbacks: PersistenceCallbacks, + declared: PersistenceCapabilities, + sweeps: Option, + ) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities_and_extension_callbacks( + callbacks, declared, None, sweeps, + ) + } + fn wired_callbacks() -> PersistenceCallbacks { + PersistenceCallbacks { + on_persist_wallet_changeset_fn: Some(noop_wallet_changeset), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + ..Default::default() + } + } + /// Everything the bit needs except the atomic round. + fn declared() -> PersistenceCapabilities { + PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(PersistenceCapabilities::ATOMIC_CHANGESETS) + } + + // The pre-sweep-aware binary shape: legacy changeset callback + // wired, declaration present (a host blindly OR-ing bits), but no + // extension slot — absent. + assert!(!persister_with(wired_callbacks(), declared(), None) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + + // Extension slot wired and declared, but no changeset callback to + // persist the rows a sweep would correct: absent. + assert!(!persister_with( + PersistenceCallbacks::default(), + declared(), + Some(noop_wallet_changeset_sweeps) + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + + // Structurally complete but never declared: absent. + assert!(!persister_with( + wired_callbacks(), + PersistenceCapabilities::NONE, + Some(noop_wallet_changeset_sweeps) + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + + // Structurally complete and declared, but without the atomic round + // the split transport needs: absent. Sweeps arrive on their own + // call, so a host with no begin/end boundary can make the changeset + // durable and stop before the removal lands. + assert!(!persister_with( + wired_callbacks(), + PersistenceCapabilities::CORE_SWEEP_REMOVAL, + Some(noop_wallet_changeset_sweeps) + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + + // Declared atomic, but the begin/end pair is not actually wired: + // absent. The declaration alone cannot bracket the two calls. + assert!(!persister_with( + PersistenceCallbacks { + on_persist_wallet_changeset_fn: Some(noop_wallet_changeset), + ..Default::default() + }, + declared(), + Some(noop_wallet_changeset_sweeps) + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + + // Everything present: attested. + assert!(persister_with( + wired_callbacks(), + declared(), + Some(noop_wallet_changeset_sweeps) + ) + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL)); + } + + /// The delivery contract of the extension transport itself: a + /// sweep-carrying round hands its batches to the extension slot AFTER + /// the changeset callback, within the same round, in emission order and + /// with payloads intact — order is the one property a persister cannot + /// reconstruct, since a later batch can keep a coin spent that an + /// earlier one freed. The same round against a persister whose + /// extension never proved the slot must still succeed with the sweeps + /// simply undelivered: the adapter's `CORE_SWEEP_REMOVAL` gate is what + /// turns that into a withheld watermark rather than a false success. + #[test] + fn store_delivers_sweeps_through_the_extension_slot_after_the_changeset() { + use dashcore::hashes::Hash as _; + use platform_wallet::changeset::changeset::SweepBatch; + use platform_wallet::changeset::CoreChangeSet; + + #[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_sweeps( + ctx: *mut c_void, + _wallet_id: *const u8, + sweeps: *const SweepBatchFFI, + sweeps_count: usize, + ) -> i32 { + let sink = &*(ctx as *const Sink); + let mut events = sink.events.lock().unwrap(); + for batch in slice::from_raw_parts(sweeps, sweeps_count) { + let txids = slice::from_raw_parts(batch.txids, batch.txids_count); + let released = if batch.released_outpoints.is_null() { + &[][..] + } else { + slice::from_raw_parts(batch.released_outpoints, batch.released_outpoints_count) + }; + let winner_height = if batch.has_winner_mined_height { + format!("Some({})", batch.winner_mined_height) + } else { + "None".to_string() + }; + events.push(format!( + "sweep txids={:?} winner={} height={} released={:?}", + txids.iter().map(|t| t[0]).collect::>(), + batch.superseded_by[0], + winner_height, + released + .iter() + .map(|o| (o.txid[0], o.vout)) + .collect::>(), + )); + } + 0 + } + + fn sweep_changeset() -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + sweeps: vec![ + // Block-context: the winner's mined height crosses. + SweepBatch { + txids: vec![dashcore::Txid::from_byte_array([0x11; 32])], + superseded_by: dashcore::Txid::from_byte_array([0x22; 32]), + winner_mined_height: Some(910), + released_outpoints: vec![dashcore::OutPoint::new( + dashcore::Txid::from_byte_array([0x33; 32]), + 7, + )], + claimed_inputs: vec![], + }, + // IS-locked winner: no height — the consumer must + // see the absence, not a fabricated zero. + SweepBatch { + txids: vec![ + dashcore::Txid::from_byte_array([0x44; 32]), + dashcore::Txid::from_byte_array([0x55; 32]), + ], + superseded_by: dashcore::Txid::from_byte_array([0x66; 32]), + winner_mined_height: None, + released_outpoints: vec![], + claimed_inputs: vec![], + }, + ], + ..Default::default() + }), + ..Default::default() + } + } + + 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 persister = FFIPersister::new_with_persistence_capabilities_and_extension_callbacks( + callbacks, + PersistenceCapabilities::CORE_SWEEP_REMOVAL, + None, + Some(record_sweeps), + ); + persister + .store([1u8; 32], sweep_changeset()) + .expect("sweep round must succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec![ + "changeset".to_string(), + "sweep txids=[17] winner=34 height=Some(910) released=[(51, 7)]".to_string(), + "sweep txids=[68, 85] winner=102 height=None released=[]".to_string(), + ], + ); + drop(persister); + + // No extension slot: the round still succeeds, the changeset + // callback still fires, and the sweeps are never delivered — the + // legacy-host shape, safe because such a persister can never attest + // CORE_SWEEP_REMOVAL (see the capability test above). + 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 persister = FFIPersister::new_with_persistence_capabilities( + callbacks, + PersistenceCapabilities::NONE, + ); + persister + .store([1u8; 32], sweep_changeset()) + .expect("sweepless-host round must still succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec!["changeset".to_string()] + ); + drop(persister); + } + + /// 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 + /// read out of the bincode blob, a round with no chainlock never fires + /// it, and a host without the slot still succeeds — it just never + /// learns the finality boundary and must hold its sweep tombstones. + #[test] + fn store_delivers_the_chainlock_height_through_the_extension_slot() { + use platform_wallet::changeset::CoreChangeSet; + + #[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_chain_lock_height( + ctx: *mut c_void, + _wallet_id: *const u8, + chain_lock_height: u32, + ) -> i32 { + let sink = &*(ctx as *const Sink); + sink.events + .lock() + .unwrap() + .push(format!("chain_lock_height={chain_lock_height}")); + 0 + } + fn chain_lock_at(height: u32) -> dashcore::ephemerealdata::chain_lock::ChainLock { + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::hashes::Hash as _; + use dashcore::BlockHash; + dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: height, + block_hash: BlockHash::from_byte_array([0xCC; 32]), + signature: BLSSignature::from([0u8; 96]), + } + } + + 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 persister = FFIPersister::new_with_persistence_capabilities_and_all_extension_callbacks( + callbacks, + PersistenceCapabilities::NONE, + None, + None, + Some(record_chain_lock_height), + ); + // A round with no chainlock: the slot stays silent. + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(10), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("chainlock-less round must succeed"); + // A chainlock-advancing round: the numeric height crosses, after + // the changeset callback. + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + last_applied_chain_lock: Some(chain_lock_at(4_242)), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("chainlock round must succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec![ + "changeset".to_string(), + "changeset".to_string(), + "chain_lock_height=4242".to_string(), + ], + ); + drop(persister); + + // Host without the slot: the same round still succeeds. + 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 persister = FFIPersister::new_with_persistence_capabilities( + callbacks, + PersistenceCapabilities::NONE, + ); + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + last_applied_chain_lock: Some(chain_lock_at(4_242)), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("slotless-host chainlock round must still succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec!["changeset".to_string()] + ); + drop(persister); + } + #[test] fn asset_lock_reconciliation_requires_every_callback_leg() { fn complete_callbacks() -> PersistenceCallbacks { @@ -6704,7 +7349,8 @@ mod tests { .union(PersistenceCapabilities::PROVIDER_TRANSACTIONS) .union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE) .union(PersistenceCapabilities::WALLET_RESTORE) - .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); + .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) + .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); cb.on_changeset_begin_fn = Some(noop_begin); cb.on_changeset_end_fn = Some(noop_end); cb.on_persist_account_registrations_fn = Some(noop_registrations); @@ -6715,7 +7361,15 @@ mod tests { cb.on_load_wallet_list_free_fn = Some(noop_free_wallets); cb.on_persist_wallet_changeset_fn = Some(noop_wallet_changeset); cb.on_persist_token_balances_fn = Some(noop_token_balances); - let capabilities = declared_persister(cb, expected).persistence_capabilities(); + // "Fully wired" includes the extension's sweeps slot — the legacy + // vtable alone can no longer attest CORE_SWEEP_REMOVAL. + let capabilities = FFIPersister::new_with_persistence_capabilities_and_extension_callbacks( + cb, + expected, + None, + Some(noop_wallet_changeset_sweeps), + ) + .persistence_capabilities(); assert_eq!(capabilities, expected); assert!(capabilities.contains(PersistenceCapabilities::INVITATION_CREATION)); @@ -6784,17 +7438,44 @@ mod tests { std::mem::size_of::() ); assert_eq!(PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, 1); - // The extension grows ADDITIVELY under version 1 (size-gated - // reads); pin the current field order and terminal slot so an - // accidental reorder — which would silently misread every older - // host's callbacks — fails here. - assert!( + // The extension is append-only under version 1 (size-gated reads): + // pin the exact slot adjacency so every historical struct_size + // boundary keeps meaning what it meant when a host declared it. + // The DPNS slot's end is exactly where the tracked-masternode trio + // begins (mainline shipped the trio at those offsets before the + // sweeps/chainlock slots merged in, so the trio keeps them), the + // trio's end is where the sweeps slot begins, the sweeps slot's + // end is where the chainlock-height slot begins, and the + // chainlock-height slot is currently terminal. Reordering any of + // them would silently misread every extension already in the field. + assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, on_persist_dpns_name_states_fn - ) < std::mem::offset_of!( + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_tracked_masternodes_fn + ) + ); + assert_eq!( + std::mem::offset_of!( PersistenceCallbacksExtension, on_persist_tracked_masternodes_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_tracked_masternodes_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_tracked_masternodes_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_tracked_masternodes_free_fn ) ); assert_eq!( @@ -6802,6 +7483,26 @@ mod tests { PersistenceCallbacksExtension, on_load_tracked_masternodes_free_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_sweeps_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_sweeps_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_chain_lock_height_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_chain_lock_height_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -6848,6 +7549,18 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS, PersistenceCapabilities::TRACKED_ASSET_LOCKS.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES, + PersistenceCapabilities::TRACKED_MASTERNODES.bits() + ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL, + PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits() + ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS, + PersistenceCapabilities::DASHPAY_PAYMENTS.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES @@ -7058,7 +7771,7 @@ mod tests { use key_wallet::account::{Account, AccountType, StandardAccountType}; use key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::Wallet; /// Regression: restored pool addresses must be tagged with the @@ -7206,9 +7919,7 @@ mod tests { // base58-checked string. Same pattern the upstream // `account_collection_test.rs` uses. let mnemonic = Mnemonic::from_phrase( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, - ) + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); let master = ExtendedPrivKey::new_master(Network::Testnet, &seed) @@ -7240,9 +7951,7 @@ mod tests { /// when it skips absent source families. fn test_managed_wallet_info_with_account(account_type: AccountType) -> ManagedWalletInfo { let mnemonic = Mnemonic::from_phrase( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, - ) + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); let master = ExtendedPrivKey::new_master(Network::Testnet, &seed) @@ -7356,9 +8065,7 @@ mod tests { /// pool — the restore target the provider arms route into. fn test_managed_wallet_info_with_provider_owner() -> ManagedWalletInfo { let mnemonic = Mnemonic::from_phrase( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, - ) + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); let master = ExtendedPrivKey::new_master(Network::Testnet, &seed) @@ -7799,9 +8506,7 @@ mod tests { #[test] fn account_xpub_survives_persist_restore_round_trip() { let mnemonic = Mnemonic::from_phrase( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, - ) + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about") .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes( diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 8149fb16e23..fd28bacdce4 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -93,7 +93,7 @@ erDiagram INTEGER height "NULL if unconfirmed" INTEGER account_index INTEGER spent "0 | 1" - BLOB spent_in_txid "NULL until spend; cleared by trigger on tx delete" + BLOB spent_in_txid "set by apply_sweep for an unresolved held input; else NULL" } CORE_INSTANT_LOCKS { @@ -381,10 +381,16 @@ is `1` once block context is present. ### `core_utxos` -One row per UTXO, spent or unspent. `spent_in_txid` is set to NULL -by a trigger when its referenced `core_transactions` row is deleted -(instead of a native `ON DELETE SET NULL`, which would also null the -NOT NULL `wallet_id` column). +One row per UTXO, spent or unspent. `spent_in_txid` is written only by +`apply_sweep`, naming the winner that took an input a swept loser claimed +but this store had no released record for. Its presence gates the funding +UTXO's own later upsert (`execute_upsert_utxo`): a coin held spent with a +`spent_in_txid` stays spent when the wallet redelivers it, unlike a coin +held spent with none (the ordinary "sweep couldn't resolve it" state, which +does clear on redelivery). It is set to NULL by a trigger when its +referenced `core_transactions` row is deleted (instead of a native +`ON DELETE SET NULL`, which would also null the NOT NULL `wallet_id` +column) — and by a later sweep that releases the same outpoint. - PK: `(wallet_id, outpoint)`. - FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. diff --git a/packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs b/packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs new file mode 100644 index 00000000000..a2de745804f --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V007__utxo_sweep_winner_height.rs @@ -0,0 +1,55 @@ +//! Anchor sweep tombstones at their winner's mined height and pin the +//! chainlock finality boundary. +//! +//! `core_utxos.winner_mined_height` is the mined height of the +//! transaction that beat an unmaterialised sweep tombstone's outpoint — +//! the placeholder row `apply_sweep` writes for a held input whose +//! funding output has never classified (`height IS NULL AND spent = 1`; +//! no other writer leaves `height` NULL). The height is carried on the +//! sweep event itself (`TransactionsSwept::winner_mined_height`), and +//! `apply_sweep` writes the placeholder for EVERY non-released held +//! input in EVERY sweep context — only the stamp differs. A +//! block-context sweep (winner actually mined) stamps the winner's +//! height, and the collector in `core_state::apply` evicts the row +//! exactly when `min(chainlock_height, synced_height)` reaches that +//! height — `prune_finalized_observed_spends`' condition verbatim, with +//! no observation-age margin. An InstantSend-locked, unmined winner +//! writes the same row with the stamp NULL — under DIP-10 its lock +//! alone settles the input, but it carries no height to key a lifetime +//! on — and the collector never takes an unstamped row: it resolves +//! only through proof, when the funding upsert materialises it, a later +//! block-context sweep re-stamps it into the collectible set, or a +//! release deletes it. See `CORE_SWEEP_REMOVAL` and the `apply_sweep` +//! doc in `core_state.rs` for why an unstamped hold must survive (it is +//! the only durable carrier of upstream's in-memory `spent_outpoints` +//! hold across a restart) and what bounds the foreign-input residue. +//! +//! `core_sync_state.chainlock_height` is the monotonic-max height of +//! the last applied chainlock, mirrored from +//! `CoreChangeSet::last_applied_chain_lock` (previously dropped by +//! this store). It is one half of the collector's finality boundary; +//! rows are never collected before a chainlock has been persisted, +//! matching upstream's "no-op until a chainlock has been applied". +//! +//! The partial index covers exactly the unmaterialised rows — the +//! collector's scan set is the stamped subset of these — so the +//! per-round sweep touches tombstones only, not the wallet's full +//! spent history. +//! +//! Edited in place (formerly `V006__utxo_tombstone_stamp`, column +//! `held_since_height`) under the same pre-release policy V001's test +//! documents: nothing shipped has applied this migration, and a dev +//! database that did apply the old shape fails refinery's divergence +//! check and must be recreated. Renumbered `V006` → `V007` when the +//! mainline's `V006__tracked_masternodes` merged in ahead of this +//! unmerged branch: version numbers, like capability bits, are +//! append-only and the already-merged assignment keeps its slot. + +pub fn migration() -> String { + "ALTER TABLE core_utxos ADD COLUMN winner_mined_height INTEGER; + ALTER TABLE core_sync_state ADD COLUMN chainlock_height INTEGER; + CREATE INDEX idx_core_utxos_unmaterialized + ON core_utxos(wallet_id, winner_mined_height) + WHERE height IS NULL;" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index d784f344ecf..4cc05bd05d2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -829,6 +829,20 @@ impl PlatformWalletPersistence for SqlitePersister { // Do NOT attest WALLET_RESTORE (and therefore not provider restore): // `load()` still reports `ClientStartState::wallets` in // `LOAD_UNIMPLEMENTED`. Shielded state lives in a separate store. + // `core_state::apply_sweep` deletes the loser row and resolves every + // input it claimed via `released` — including the held-but-unfunded + // case, where it leaves a `core_utxos` placeholder keyed by outpoint + // rather than by any relationship to the loser. That is what makes a + // later sweep of the winner that replaces it chain-safe with no + // extra bookkeeping: the next sweep matches the same outpoint + // directly — through the loser's decoded inputs when its row is on + // hand, and through the batch's own released set when it is not — + // so it repoints or releases the placeholder regardless of how many + // sweeps deep it is. A placeholder that never materialises is + // bounded, not permanent: `core_state::collect_finalized_tombstones` + // evicts it once the persisted chainlock finality boundary passes + // its creation stamp, so foreign-input junk from swept incoming + // payments cannot grow the store without limit. PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) @@ -837,6 +851,8 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) .union(PersistenceCapabilities::TRACKED_MASTERNODES) + .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) + .union(PersistenceCapabilities::DASHPAY_PAYMENTS) } fn persist_tracked_masternodes( diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index b0c21a58a47..216897725fd 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -73,10 +73,19 @@ pub fn apply( if !cs.removed.is_empty() { // Same terminal rule as the upsert guard: a stored `consumed` // row is never deleted by a stale tombstone. Consumed rows are - // deliberately retained for historical lookup, and the only - // removal emitter (`untrack_asset_lock`) fires exclusively for - // Built rows whose broadcast was rejected — so a removal - // reaching a consumed row is by construction a stale write. + // deliberately retained for historical lookup, and neither + // removal producer can legitimately name one — a Built row + // rejected at broadcast (`untrack_asset_lock`) never got that + // far, and a sweep of the funding transaction + // (`remove_tracked_asset_locks_for_swept`) only tombstones + // entries still tracked, which a consumed lock no longer is — + // so a removal reaching a consumed row is by construction a + // stale write. `AssetLockChangeSet::merge` guarantees a stored + // changeset never carries an upsert and a tombstone for the + // same outpoint (a reinstating reconstruction cancels a folded + // sweep tombstone; a folding tombstone takes the dead upsert + // with it), so the upserts-then-removals order here is layout, + // not load-bearing sequencing. let mut stmt = tx.prepare_cached( "DELETE FROM asset_locks \ WHERE wallet_id = ?1 AND outpoint = ?2 AND status != 'consumed'", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 129819b0bce..378e54e8a02 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -2,10 +2,12 @@ #[cfg(any(test, feature = "__test-helpers"))] use std::collections::BTreeMap; +use std::collections::HashSet; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; @@ -19,6 +21,13 @@ pub fn apply( wallet_id: &WalletId, cs: &CoreChangeSet, ) -> Result<(), WalletStorageError> { + // Adapter-internal evidence, settled into `sweeps[..].claimed_inputs` + // or `spent_utxos` before any changeset reaches a store; a producer + // that bypassed the adapter would leave spends this round never writes. + debug_assert!( + cs.dropped_record_spends.is_empty(), + "unsettled dropped-record spend evidence reached the SQLite store" + ); if !cs.records.is_empty() { let mut stmt = tx.prepare_cached( "INSERT INTO core_transactions \ @@ -126,8 +135,550 @@ pub fn apply( ])?; } } - if cs.last_processed_height.is_some() || cs.synced_height.is_some() { - upsert_sync_state(tx, wallet_id, cs.last_processed_height, cs.synced_height)?; + let chainlock_height = cs + .last_applied_chain_lock + .as_ref() + .map(|cl| cl.block_height); + let heights_advanced = cs.last_processed_height.is_some() + || cs.synced_height.is_some() + || chainlock_height.is_some(); + if heights_advanced { + upsert_sync_state( + tx, + wallet_id, + cs.last_processed_height, + cs.synced_height, + chainlock_height, + )?; + } + // Sweeps run last so a winner arriving in this very changeset has its + // own rows committed before the removal below touches the coins it took, + // and batch by batch in order: each sweep is only true of the wallet it + // saw, so a later one keeping a coin spent has to be able to correct an + // earlier one that freed it. + if cs.sweeps.is_empty() { + // The ordinary round. Everything below serves the sweep loop, and + // building the survivor set would hash every input of every record + // for a loop that never runs — with the write transaction open. + if heights_advanced { + collect_finalized_tombstones(tx, wallet_id)?; + } + return Ok(()); + } + + // The surviving claims are a property of the whole changeset, not of any + // one batch, so they are built once: the adapter folds up to a full drain + // into a single store, and rebuilding them per batch would re-hash every + // swept txid and every surviving record input once per sweep, with the + // write transaction open the whole time. + // + // `apply_sweep` below is what attributes a held input to `superseded_by` + // via `spent_in_txid`, and that only happens once it runs — so at this + // point in the round the table cannot yet tell a live claim in *this* + // round from the one a sweep is about to displace. The changeset carries + // the answer instead: any record in this round that is not swept by *any* + // batch and spends a released outpoint is that live claim, and the coin + // stays spent. + let swept_txids: HashSet = cs + .sweeps + .iter() + .flat_map(|b| b.txids.iter()) + .copied() + .collect(); + let claimed_by_survivors: HashSet = cs + .records + .iter() + .filter(|record| !swept_txids.contains(&record.txid)) + .flat_map(|record| record.transaction.input.iter()) + .map(|input| input.previous_output) + .collect(); + // The changeset is not the whole answer, though. Upstream computes + // `released_outpoints` from its *live* records, and under the default + // `keep-finalized-transactions = off` a chainlocked record is pruned to + // its bare txid — the pinned `TransactionsSwept::released_outpoints` + // doc records this exact limitation ("the inputs of a pruned record + // survive nowhere else, so this cannot be resolved at this layer"). + // It CAN be resolved at this layer: this store never prunes a + // `core_transactions` row on finalization, so the full input set of + // every settled spend the wallet has forgotten is still on disk. A + // release naming a coin such a record still claims is upstream + // reporting its own amnesia — honouring it flips the materialized UTXO + // to `spent = 0` and hands a provably consumed coin back as spendable + // after the next load, a guaranteed double spend. The same applies + // after a restart for every NETWORK-FINAL record (IS-locked, in-block, + // chainlocked): hydration rebuilds the in-memory wallet without its + // transaction history, so every settled claim the store holds is one + // upstream can no longer see. Bare mempool rows are deliberately not + // part of the veto — see `surviving_stored_input_claims` for why a + // stale one must not strand a legitimately released coin. + // + // `stored_input_claims` is therefore upstream's own `retain_unclaimed` + // predicate — "drop outpoints some surviving record still spends" — + // re-evaluated against the unpruned history. Built lazily and at most + // once per round: only a batch whose released set survives the + // in-round filter above pays for it, and the common sweep (a resend + // whose winner spends every input its loser did) releases nothing. + let mut stored_claims: Option> = None; + for batch in &cs.sweeps { + // Only this stays per batch: a release is true of the wallet its own + // sweep saw, which is what lets a later batch correct an earlier one. + let mut released: HashSet = batch + .released_outpoints + .iter() + .filter(|outpoint| !claimed_by_survivors.contains(outpoint)) + .copied() + .collect(); + if !released.is_empty() { + let claims = match stored_claims.as_ref() { + Some(claims) => claims, + None => { + stored_claims = + Some(surviving_stored_input_claims(tx, wallet_id, &swept_txids)?); + stored_claims.as_ref().expect("just assigned") + } + }; + released.retain(|outpoint| !claims.contains(outpoint)); + } + for loser_txid in &batch.txids { + apply_sweep( + tx, + wallet_id, + loser_txid, + &batch.superseded_by, + &released, + &swept_txids, + batch.winner_mined_height, + )?; + } + // Inputs the batch itself vouches for, settled exactly like a + // deleted loser's inputs. `apply_sweep` above reads a loser's + // inputs off its stored row, and a loser swept before its own + // detection reached this store has no row to read — the detection + // event's input details, carried on the batch, are then the only + // evidence that the winner consumed this wallet's coin. Repeats of + // inputs a stored row already settled are harmless: the same + // UPDATE lands on the same value. + for outpoint in &batch.claimed_inputs { + settle_swept_input( + tx, + wallet_id, + *outpoint, + &batch.superseded_by, + &released, + &swept_txids, + batch.winner_mined_height, + )?; + } + // Releases are outpoint-keyed facts, so they are applied by outpoint + // once the batch's losers are done — not only through each loser's + // decoded inputs above. A chained-sweep claim is a `core_utxos` + // placeholder that exists independently of any transaction row, and + // the loser now freeing it need not have one: a fatal flush error + // wipes a buffered round (the winner's record with it) while the + // faulted wallet keeps persisting later rounds, and `apply_sweep` + // above returns before its input loop when the swept txid has no + // row. Dropping the release set there would leave the `:340` valve + // holding the placeholder's `spent_in_txid` forever — the release + // is the one channel that clears it. Running after the loser loop + // rather than inside it changes nothing for inputs the loop already + // freed (same UPDATE, idempotent), and a coin a surviving record in + // this round re-claimed was already filtered out of `released` + // above. + if !released.is_empty() { + // A released claim that never materialised is deleted outright + // rather than flipped to `spent = 0`: the row is all placeholder + // (`value = 0`, `script = X''`, `height` NULL — no writer but the + // tombstone insert leaves `height` NULL), so releasing it in + // place would surface a zero-value phantom coin through + // `list_unspent_utxos`. No row is the correct end state — if the + // funding output ever classifies, its ordinary upsert creates + // the real row freshly unspent, exactly as if the dead claim had + // never existed. Materialised rows carry real funding data and + // are released in place as before. + let mut release_drop_stmt = tx.prepare_cached( + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2 AND height IS NULL", + )?; + let mut release_stmt = tx.prepare_cached( + "UPDATE core_utxos SET spent = 0, spent_in_txid = NULL \ + WHERE wallet_id = ?1 AND outpoint = ?2", + )?; + for outpoint in &released { + let key = blob::encode_outpoint(outpoint)?; + let dropped = release_drop_stmt.execute(params![wallet_id.as_slice(), &key[..]])?; + if dropped == 0 { + release_stmt.execute(params![wallet_id.as_slice(), &key[..]])?; + } + } + } + } + if heights_advanced { + collect_finalized_tombstones(tx, wallet_id)?; + } + Ok(()) +} + +/// The union of every input outpoint claimed by a surviving +/// `core_transactions` row — every row except this round's swept losers, +/// whose deletion the round itself performs. +/// +/// This is the durable mirror of upstream's `retain_unclaimed` claimed-set, +/// with one decisive difference: it includes records the in-memory wallet +/// has pruned (chainlocked, under the default +/// `keep-finalized-transactions = off`) or lost across a restart. +/// +/// Only NETWORK-FINAL claimants count: InstantSend-locked (settled under +/// DIP-10 the moment the lock lands), in-block, or chainlocked. A bare +/// `Mempool` row is deliberately not settled-spend evidence, because it is +/// the one context that can go stale forever: an evicted or abandoned +/// mempool transaction has no removal path in this store other than a later +/// sweep (upstream's abandon path emits no events — +/// dashpay/rust-dashcore#976), and restoration deliberately does not +/// repopulate ordinary transaction history, so nothing ever re-asserts or +/// retracts the row. Letting it veto an authoritative release would leave +/// the coin attributed to an unrelated winner and durably spent — the +/// mirror image of the wrong-release bug this guard exists to stop. This is +/// also exactly the mobile stores' rule: their link guard protects a +/// network-final spender's link and lets a mempool link be replaced. A LIVE +/// mempool claim loses nothing here: in-session upstream holds the record +/// and never names its inputs released, and within the round +/// `claimed_by_survivors` carries the changeset's own mempool records. The +/// one accepted trade: after a restart a still-alive mempool claimant on +/// disk no longer vetoes, so the release wins and the coin may be +/// transiently re-offered while that pending spend races — self-resolving +/// when the pending spend confirms or dies, and strictly better than a +/// permanent strand. +/// +/// Fails CLOSED. This scan is the final guard against re-crediting a +/// consumed coin, so a malformed stored key must fail the round rather than +/// silently drop that row's veto: a `txid` column of the wrong length and a +/// record blob whose decoded `TransactionRecord::txid` disagrees with the +/// typed key (the key is what excludes a row as a swept loser) are both +/// `BlobDecode` errors, matching the other typed-column readers. +/// +/// One pass over the wallet's rows, decoding each blob once — the same +/// build-the-set-then-probe shape (and rationale) as upstream's +/// `retain_unclaimed`: released sets follow the input count of a +/// transaction a remote peer picks, so probing per candidate would be +/// `O(released × history)` instead. The pass itself is `O(history)` blob +/// decodes, paid only by a round whose sweep actually frees candidate +/// coins — rare organically, and an attacker can only force one per +/// on-chain final transaction they pay for. +fn surviving_stored_input_claims( + tx: &Transaction<'_>, + wallet_id: &WalletId, + swept_txids: &HashSet, +) -> Result, WalletStorageError> { + use dashcore::hashes::Hash; + + let mut stmt = + tx.prepare_cached("SELECT txid, record_blob FROM core_transactions WHERE wallet_id = ?1")?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut claims: HashSet = HashSet::new(); + while let Some(row) = rows.next()? { + let txid_bytes: Vec = row.get(0)?; + let Ok(txid_array) = <[u8; 32]>::try_from(txid_bytes.as_slice()) else { + return Err(WalletStorageError::blob_decode( + "core_transactions.txid must be exactly 32 bytes", + )); + }; + let key_txid = dashcore::Txid::from_byte_array(txid_array); + if swept_txids.contains(&key_txid) { + continue; + } + let blob_bytes: Vec = row.get(1)?; + let record: TransactionRecord = blob::decode(&blob_bytes)?; + if record.txid != key_txid { + return Err(WalletStorageError::blob_decode( + "core_transactions.txid disagrees with the decoded record's txid", + )); + } + + if matches!(record.context, TransactionContext::Mempool) { + continue; + } + claims.extend( + record + .transaction + .input + .iter() + .map(|input| input.previous_output), + ); + } + Ok(claims) +} + +/// Delete a swept transaction's row and outputs, then resolve the coins it +/// claimed to spend. +/// +/// A swept transaction was a recorded spend that a later, final transaction +/// provably beat to one of its inputs, so it can never confirm — the wallet +/// has already dropped it. Leaving the mirrored row in place would hand it +/// back at the next `load()` and replay a balance the wallet has already +/// corrected. It would also leave an InstantSend loser answerable through +/// `get_core_tx_record`, which sent-payment reconciliation reads as final and +/// would use to advance a dead DashPay payment to `Confirmed`. +/// +/// Deleting the row and the UTXOs it created is the easy half. The coins it +/// claimed to *spend* split in two, and `released` — computed upstream and +/// carried on the changeset — is the authority on which is which: an input +/// named there came free, because no surviving transaction spends it too; +/// every other input the loser claimed was taken by the transaction that beat +/// it and is gone for good. +/// +/// Recomputing that split here is not an option even though this schema +/// stores whole records. The transaction that took the rest need not be +/// wallet-relevant at all — it can spend our coin while paying only external +/// addresses, and then it is never recorded anywhere in this store — and even +/// a relevant one is not guaranteed to arrive in the same round as the sweep. +/// +/// A held input can also have no `core_utxos` row at all: this wallet can +/// persist the loser before its own funding output was ever classified as +/// ours, so the outpoint the loser claims to spend has nothing to update. +/// Losing that claim would matter — the funding transaction has not shown up +/// yet, and when it eventually does, the ordinary UTXO upsert would treat the +/// outpoint as freshly unspent — so a held-but-absent input gets a row of its +/// own here: `spent = 1`, `spent_in_txid = superseded_by`, everything else a +/// placeholder the real funding data overwrites on arrival. +/// `execute_upsert_utxo`'s conflict clause is what makes that placeholder +/// durable — it refuses to clear `spent` while `spent_in_txid` is set, so the +/// claim survives the funding upsert instead of being upserted away by it. +/// +/// The placeholder is created for EVERY sweep context; only the stamp +/// differs. A BLOCK-CONTEXT sweep (`winner_mined_height` is `Some`) +/// stamps the winner's own mined height — the projection of key-wallet's +/// `observed_spent_outpoints`, which maps each outpoint observed spent in +/// a block to the height of the block that spent it — and +/// `collect_finalized_tombstones` evicts the row once the chainlock +/// finality boundary reaches that height, key-wallet's +/// `prune_finalized_observed_spends` condition verbatim. A +/// MEMPOOL-CONTEXT sweep (IS-locked winner, unmined) writes the same row +/// UNSTAMPED (`winner_mined_height` NULL), and the collector never takes +/// an unstamped row. The in-memory model an unstamped row mirrors is not +/// `observed_spent_outpoints` (which indeed records nothing for an +/// unconfirmed spend) but the account's `spent_outpoints`: +/// `drop_conflicted_transactions` deletes the loser and RETAINS the +/// winner's shared inputs there — a hold that carries no height, because +/// under DIP-10 the IS lock alone settles the input. That set is +/// `serde(skip_serializing)` upstream and rebuilt from live records on +/// load, so after the sweep no record can reconstruct it; this row is the +/// hold's only durable carrier, and dropping it lets a post-restart +/// funding delivery credit a coin the network has already consumed. +/// +/// Nothing may collect an unstamped row, ever: an IS-locked winner has no +/// mining deadline, and the funding transaction of an input it spends may +/// itself be IS-locked and unmined (DIP-10 eligibility allows chained +/// locks), so no height watermark can prove the funding output "delivered +/// or never will be". An unstamped row instead leaves the set only +/// through proof: the funding upsert materialises it (a wallet-owned +/// claim — DIP-10 eligibility means the funding tx is mined or will mine, +/// and BIP158 matches its block by our script, so delivery is guaranteed; +/// the row gains a real `height` and becomes an ordinary spent coin), a +/// later block-context sweep re-points it and stamps it into the +/// collectible set, or a release deletes it. +/// +/// The residue is foreign inputs — a swept INCOMING payment reaches this +/// loop too, and a sender-owned input's funding output never delivers, so +/// its unstamped row is permanent. It cannot be gated by ownership +/// because nothing anywhere can prove an input foreign (`input_details` +/// and `direction` are computed from the wallet's UTXO snapshot AT RECORD +/// TIME; dashpay/rust-dashcore#968 — the once-proposed "held outpoints +/// attested ours" set is empty by construction). What bounds the residue +/// is attack cost, not collection: masternodes lock first-seen, so for +/// the winner to earn the IS lock this sweep requires, the conflicting +/// loser must have been delivered straight to this wallet while withheld +/// from the network, and every batch of rows costs the attacker a +/// fee-paying, network-accepted double-spend. The unconditional-placeholder +/// shape this narrows (every context leaking rows with no collector at +/// all) does not return: block-context rows still collect at the finality +/// boundary, and only the IS-context shared-input residue is permanent. +/// +/// Idempotent: a txid this store never recorded is a successful no-op, not an +/// error. A sweep can legitimately name a transaction this wallet dropped, or +/// never derived an address for in the first place. Only the loser-scoped +/// work is skipped in that case — the batch's released outpoints are applied +/// by the caller, outside this function, precisely so a missing row cannot +/// swallow them. +fn apply_sweep( + tx: &Transaction<'_>, + wallet_id: &WalletId, + loser_txid: &dashcore::Txid, + superseded_by: &dashcore::Txid, + released: &HashSet, + swept_txids: &HashSet, + winner_mined_height: Option, +) -> Result<(), WalletStorageError> { + let loser_blob: Option> = tx + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + |row| row.get(0), + ) + .optional()?; + let Some(loser_blob) = loser_blob else { + return Ok(()); + }; + let loser: TransactionRecord = blob::decode(&loser_blob)?; + + tx.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + )?; + // An InstantSend-locked loser is evictable by a chainlocked winner, so a + // swept transaction can own a row here. Nothing ties that table to + // `core_transactions` — no foreign key, no trigger — so the lock would + // outlive the transaction it describes forever. + tx.execute( + "DELETE FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(loser_txid)], + )?; + let mut delete_output_stmt = + tx.prepare_cached("DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2")?; + for vout in 0..loser.transaction.output.len() as u32 { + let op = blob::encode_outpoint(&dashcore::OutPoint { + txid: *loser_txid, + vout, + })?; + delete_output_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; + } + drop(delete_output_stmt); + + for input in &loser.transaction.input { + settle_swept_input( + tx, + wallet_id, + input.previous_output, + superseded_by, + released, + swept_txids, + winner_mined_height, + )?; + } + + Ok(()) +} + +/// Settle one input a swept transaction claimed: freed if `released` names +/// it, otherwise held spent and attributed to `superseded_by`, with a +/// placeholder row when its funding output has not materialised. Shared by +/// [`apply_sweep`] (walking a stored loser's inputs) and the caller's pass +/// over [`SweepBatch::claimed_inputs`](platform_wallet::changeset::changeset::SweepBatch::claimed_inputs) +/// (inputs the batch vouches for when no row exists to walk). Idempotent: +/// the same input settled twice in one round lands on the same value. +/// +/// Each input is set outright rather than only touched when it changes: +/// whichever way it went, the row must end this round agreeing with the +/// wallet, and a coin the sweep did not free stays out of the unspent +/// query even if nothing had marked it spent yet (upstream sweeps only +/// unconfirmed records, whose spends this schema does not mark). +/// `spent_in_txid` moves with `spent`: a released input clears back to +/// NULL (nobody's claim), a held one is attributed to `superseded_by` so +/// the claim outlives the loser row's own deletion. +/// A held, never-materialised claim (`height IS NULL`) is re-stamped +/// with the NEW winner's mined height when this sweep has one — the +/// claim now belongs to that winner, and its height is what the +/// collector compares against the finality boundary. An IS-locked +/// winner (`?5` NULL) re-points the claim but keeps the existing stamp: +/// the earlier block-context observation stands, exactly as upstream's +/// `observed_spent_outpoints` entry is never retracted by an +/// unconfirmed conflict, and collection at the old height stays sound — +/// the funding output of a spent outpoint is mined at or below the +/// height of ANY block-context spender of it, so the boundary passing +/// that height still proves the funding was delivered or never will be. +/// Materialised rows (`height` set) keep their NULL stamp — they are +/// outside the collector's reach either way. +fn settle_swept_input( + tx: &Transaction<'_>, + wallet_id: &WalletId, + outpoint: dashcore::OutPoint, + superseded_by: &dashcore::Txid, + released: &HashSet, + swept_txids: &HashSet, + winner_mined_height: Option, +) -> Result<(), WalletStorageError> { + let mut spend_stmt = tx.prepare_cached( + "UPDATE core_utxos SET spent = ?3, spent_in_txid = ?4, \ + winner_mined_height = CASE \ + WHEN ?3 AND height IS NULL THEN COALESCE(?5, winner_mined_height) \ + ELSE winner_mined_height END \ + WHERE wallet_id = ?1 AND outpoint = ?2", + )?; + // Only reached for a held input with no existing row — see the doc + // comment on `apply_sweep`. `value`/`script`/`height`/`account_index` + // are placeholders; the funding UTXO's own upsert overwrites them (and, + // thanks to the `spent_in_txid` guard in `execute_upsert_utxo`, does + // not clear `spent` while doing it). `winner_mined_height` is the + // winner's own block height when the sweep has one — the row's whole + // lifetime rule for `collect_finalized_tombstones` — and NULL for an + // IS-locked, unmined winner, which the collector never touches: the + // hold then lasts until the funding upsert materialises it, a later + // block-context sweep stamps it, or a release deletes it. + let mut tombstone_stmt = tx.prepare_cached( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid, \ + winner_mined_height) \ + VALUES (?1, ?2, 0, X'', NULL, 0, 1, ?3, ?4)", + )?; + // An input funded by a transaction this same changeset also sweeps + // is a dead parent's output — nobody's coin, not something the + // winner took: upstream's descendant closure always sweeps parent + // and child together, and its release computation excludes exactly + // these outpoints (so `freed` below can never be true for one). The + // right end state is NO row, deleted here outright rather than + // assumed away or marked: + // + // - Assuming the parent's own pass deleted it fails when the + // parent's record was lost (the same record-loss threat the + // caller's by-outpoint release pass exists for) — that pass + // deletes nothing, and skipping the claim here would leave the + // dead output `spent = 0`, a phantom spendable coin `load()` + // hands back. + // - Holding it instead (`spent = 1`, `spent_in_txid = winner`, the + // ordinary path below) survives as a claim the funding upsert's + // valve then defends — against the chainlocked reinstatement + // that is the ONE event that can bring the coin back, whose + // re-emitted output must land freshly unspent. + // + // The delete is idempotent against the parent's own pass in either + // batch order, and a reinstatement re-creates the real row through + // the ordinary `utxos_added` upsert with nothing left standing in + // its way. + if swept_txids.contains(&outpoint.txid) { + let key = blob::encode_outpoint(&outpoint)?; + tx.execute( + "DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), &key[..]], + )?; + return Ok(()); + } + let key = blob::encode_outpoint(&outpoint)?; + let freed = released.contains(&outpoint); + let spent_in_txid: Option<&[u8]> = if freed { + None + } else { + Some(AsRef::<[u8]>::as_ref(superseded_by)) + }; + let affected = spend_stmt.execute(params![ + wallet_id.as_slice(), + &key[..], + !freed, + spent_in_txid, + winner_mined_height.map(i64::from) + ])?; + if affected == 0 && !freed { + // A held input with no row gets a placeholder in EVERY sweep + // context — `CORE_SWEEP_REMOVAL`'s contract: each non-released + // input retains a durable spend claim even when its funding + // TXO has not materialised yet. An IS-locked, unmined winner + // just leaves the stamp NULL, which the collector never + // touches — see the doc comment on `apply_sweep` for what + // resolves (and what bounds) an unstamped row. + tombstone_stmt.execute(params![ + wallet_id.as_slice(), + &key[..], + AsRef::<[u8]>::as_ref(superseded_by), + winner_mined_height.map(i64::from) + ])?; } Ok(()) } @@ -138,6 +689,19 @@ pub fn apply( const ACCOUNT_INDEX_BY_ADDRESS_SQL: &str = "SELECT account_index FROM core_derived_addresses WHERE wallet_id = ?1 AND address = ?2"; +// `spent` only takes the incoming value when the existing row has no +// `spent_in_txid`. A coin held spent with no spender on record is the +// documented recovery state — the wallet handing it back as a UTXO is +// what clears it. A coin held spent *with* `spent_in_txid` set is +// `apply_sweep`'s tombstone for an input the loser claimed but the funding +// row hadn't arrived for yet; the funding upsert (this statement) is +// exactly the arrival that tombstone exists to survive, so it must not +// double as the thing that erases it. `spent_in_txid` itself is left out of +// the SET list entirely — untouched, it carries the claim forward. +// `winner_mined_height` DOES clear: this statement always binds a real +// funding `height`, so the row it lands on is materialised from here on — +// permanently outside `collect_finalized_tombstones`'s reach — and a stale +// stamp would only mislead. const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) \ @@ -146,7 +710,9 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ script = excluded.script, \ height = excluded.height, \ account_index = excluded.account_index, \ - spent = excluded.spent"; + winner_mined_height = NULL, \ + spent = CASE WHEN core_utxos.spent_in_txid IS NOT NULL \ + THEN core_utxos.spent ELSE excluded.spent END"; fn execute_upsert_utxo( stmt: &mut rusqlite::CachedStatement<'_>, @@ -201,35 +767,112 @@ fn upsert_sync_state( wallet_id: &WalletId, last_processed: Option, synced: Option, + chainlock: Option, ) -> Result<(), WalletStorageError> { // Monotonic-max semantics — keep the larger of (current, new). - let current_raw: (Option, Option) = tx - .query_row( - "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id = ?1", - params![wallet_id.as_slice()], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()? - .unwrap_or((None, None)); - let current = ( - sync_height_u32("core_sync_state.last_processed_height", current_raw.0)?, - sync_height_u32("core_sync_state.synced_height", current_raw.1)?, - ); - let lp = match (current.0, last_processed) { - (Some(a), Some(b)) => Some(a.max(b)), - (a, b) => a.or(b), - }; - let sy = match (current.1, synced) { + let current = read_sync_heights(tx, wallet_id)?; + let max_or = |a: Option, b: Option| match (a, b) { (Some(a), Some(b)) => Some(a.max(b)), (a, b) => a.or(b), }; + let lp = max_or(current.0, last_processed); + let sy = max_or(current.1, synced); + let cl = max_or(current.2, chainlock); tx.execute( - "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ - VALUES (?1, ?2, ?3) \ + "INSERT INTO core_sync_state \ + (wallet_id, last_processed_height, synced_height, chainlock_height) \ + VALUES (?1, ?2, ?3, ?4) \ ON CONFLICT(wallet_id) DO UPDATE SET \ last_processed_height = excluded.last_processed_height, \ - synced_height = excluded.synced_height", - params![wallet_id.as_slice(), lp.map(i64::from), sy.map(i64::from),], + synced_height = excluded.synced_height, \ + chainlock_height = excluded.chainlock_height", + params![ + wallet_id.as_slice(), + lp.map(i64::from), + sy.map(i64::from), + cl.map(i64::from), + ], + )?; + Ok(()) +} + +/// The wallet's `(last_processed_height, synced_height, chainlock_height)` +/// watermark triple as read back from `core_sync_state`. +type SyncHeights = (Option, Option, Option); + +/// Read the wallet's [`SyncHeights`] watermarks. All-`None` when the row +/// is absent. +fn read_sync_heights( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result { + let raw: (Option, Option, Option) = tx + .query_row( + "SELECT last_processed_height, synced_height, chainlock_height \ + FROM core_sync_state WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()? + .unwrap_or((None, None, None)); + Ok(( + sync_height_u32("core_sync_state.last_processed_height", raw.0)?, + sync_height_u32("core_sync_state.synced_height", raw.1)?, + sync_height_u32("core_sync_state.chainlock_height", raw.2)?, + )) +} + +/// Evict never-materialised sweep tombstones once the chainlock finality +/// boundary reaches their winner's mined height — the storage-side mirror +/// of key-wallet's `prune_finalized_observed_spends`, same condition +/// verbatim: an entry whose spend height is at or below +/// `min(chainlock_height, synced_height)` is safe to forget, because the +/// spend at that height is chain-locked and every BIP158 filter below the +/// boundary has been matched with no false negatives, so the funding +/// transaction of the outpoint it guards — necessarily mined at or below +/// the spend's own height — has either been delivered (materialising the +/// row) or provably never will be. No observation-age margin: the stamp IS +/// the winner's height, carried on the sweep event itself, so nothing here +/// guesses when the winner mined. Rows with no stamp are never collected: +/// a mempool-context sweep (IS-locked winner, unmined) deliberately +/// writes its placeholder unstamped, because such a winner has no mining +/// deadline and no watermark can prove its inputs' funding "delivered or +/// never will be" — an unstamped row is a live hold, resolved only by the +/// funding upsert materialising it, a later block-context sweep stamping +/// it, or a release deleting it (see `apply_sweep`). +/// +/// Two passes, both narrowed to `height IS NULL` (only the tombstone +/// insert leaves `height` NULL, so the set is exactly the +/// never-materialised rows, served by the partial index): +/// +/// 1. Released leftovers (`spent = 0`) are deleted outright — a released, +/// never-materialised claim holds nothing and would read as a +/// zero-value phantom coin. The release path now deletes these +/// in-line; this pass self-heals rows written before it did. +/// 2. Held rows whose winner height is at or below the boundary are +/// collected. +/// +/// Like upstream, a no-op until a chainlock height has been persisted — +/// without a finality boundary nothing can be proven final. +fn collect_finalized_tombstones( + tx: &Transaction<'_>, + wallet_id: &WalletId, +) -> Result<(), WalletStorageError> { + tx.execute( + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND height IS NULL AND spent = 0", + params![wallet_id.as_slice()], + )?; + let (_, sy, cl) = read_sync_heights(tx, wallet_id)?; + let (Some(sy), Some(cl)) = (sy, cl) else { + return Ok(()); + }; + let boundary = cl.min(sy); + tx.execute( + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND height IS NULL AND spent = 1 \ + AND winner_mined_height <= ?2", + params![wallet_id.as_slice(), i64::from(boundary)], )?; Ok(()) } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs new file mode 100644 index 00000000000..049a22f6c15 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_transaction_sweeps.rs @@ -0,0 +1,3341 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Coverage for `core_state::apply`'s handling of `CoreChangeSet::swept_transactions` +//! (the subtractive sweep-removal field — see `core_state.rs::apply_sweep`). +//! +//! Exercises the writer directly through `core_state::apply` on a hand-rolled +//! `rusqlite::Transaction`, same style as `sqlite_structural_hardening.rs`, so +//! each case can pre-seed exactly the rows a sweep needs to reason about +//! without going through the full changeset-merge/buffer machinery. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid, SqlitePersister, SqlitePersisterConfig}; + +use dashcore::hashes::Hash; +use dashcore::{Address, Network, OutPoint, Transaction, TxIn, TxOut, Txid}; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::transaction_record::{TransactionDirection, TransactionRecord}; +use key_wallet::transaction_checking::{TransactionContext, TransactionType}; +use key_wallet::Utxo; +use platform_wallet::changeset::changeset::SweepBatch; +use platform_wallet::changeset::CoreChangeSet; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::{blob, core_state}; +use rusqlite::params; + +/// Mined height carried by every block-context sweep in these tests +/// unless a test pins its own. High enough that the pre-seeded funding +/// heights (10) and default watermarks sit below it. +const WINNER_HEIGHT: u32 = 400; + +fn p2pkh(byte: u8) -> Address { + use dashcore::address::Payload; + use dashcore::hashes::Hash; + use dashcore::PubkeyHash; + let hash = PubkeyHash::from_byte_array([byte; 20]); + Address::new(Network::Testnet, Payload::PubkeyHash(hash)) +} + +fn make_utxo(addr: &Address, txid: Txid, vout: u32, value: u64) -> Utxo { + let outpoint = OutPoint::new(txid, vout); + let txout = TxOut { + value, + script_pubkey: addr.script_pubkey(), + }; + Utxo::new(outpoint, txout, addr.clone(), 10, false) +} + +fn derive_address(conn: &rusqlite::Connection, w: &WalletId, account_index: u32, addr: &Address) { + conn.execute( + "INSERT INTO core_derived_addresses \ + (wallet_id, account_type, account_index, address, derivation_path, used) \ + VALUES (?1, 'standard', ?2, ?3, '0/0', 0)", + params![w.as_slice(), account_index as i64, addr.to_string()], + ) + .unwrap(); +} + +/// Build a `TransactionRecord` whose `transaction.input`/`.output` are the +/// real, decodable fields `apply_sweep` reads back for its outpoint math — +/// as opposed to `input_details`/`output_details`, which only cover the +/// wallet-relevant subset and are left empty here on purpose. +fn tx_record(txid: Txid, inputs: Vec, outputs: Vec) -> TransactionRecord { + let inner = Transaction { + version: 3, + lock_time: 0, + input: inputs + .into_iter() + .map(|previous_output| TxIn { + previous_output, + ..Default::default() + }) + .collect(), + output: outputs, + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + inner, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + record.txid = txid; + record +} + +fn unspent(conn: &rusqlite::Connection, w: &WalletId) -> std::collections::BTreeSet { + core_state::list_unspent_utxos(conn, w) + .unwrap() + .into_values() + .flatten() + .map(|row| row.outpoint) + .collect() +} + +fn row_exists(conn: &rusqlite::Connection, w: &WalletId, op: &OutPoint) -> bool { + let bytes = platform_wallet_storage::sqlite::schema::blob::encode_outpoint(op).unwrap(); + conn.query_row( + "SELECT 1 FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w.as_slice(), &bytes[..]], + |_| Ok(()), + ) + .optional() + .unwrap() + .is_some() +} + +use rusqlite::OptionalExtension; + +/// A changeset carrying nothing but a sweep still deletes: the loser's +/// `core_transactions` row and every `core_utxos` row it created go, even +/// though `records` / `new_utxos` / everything else on the changeset is +/// empty. This is the guard against the bug the review finding described — +/// `apply` skipping `swept_transactions` entirely because every other +/// `if !cs..is_empty()` block was false. +#[test] +fn sweep_only_changeset_deletes_loser_row_and_its_outputs() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE0); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x01); + let loser_txid = Txid::from_byte_array([0x10; 32]); + let loser = tx_record( + loser_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let loser_output = OutPoint::new(loser_txid, 0); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + new_utxos: vec![make_utxo(&addr, loser_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let conn = persister.lock_conn_for_test(); + assert!( + row_exists(&conn, &w, &loser_output), + "sanity: the loser's output must exist before the sweep" + ); + } + + // The sweep-only round: nothing else populated on the changeset. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: Txid::from_byte_array([0x11; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let record: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!(record.is_none(), "swept transaction row must be gone"); + assert!( + !row_exists(&conn, &w, &loser_output), + "the swept transaction's own output must be gone" + ); +} + +/// A sweep naming a txid this store never recorded is a successful +/// no-op — sweeps are idempotent and can arrive for a transaction this +/// wallet dropped, or ran again after the first sweep already applied. +#[test] +fn sweeping_an_unknown_txid_is_a_no_op() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE1); + ensure_wallet_meta(&persister, &w); + + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![Txid::from_byte_array([0x20; 32])], + superseded_by: Txid::from_byte_array([0x21; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).expect("unknown txid must not error"); + tx.commit().unwrap(); +} + +/// A loser can be swept while its detection event is still queued for +/// persistence. The adapter then projects the detection without its stale +/// row and hands the loser's inputs to the sweep as `claimed_inputs` — the +/// only evidence this store ever gets that the external winner consumed the +/// funding coin. The claim has to be attributed to the winner (not merely +/// marked spent): `spent_in_txid` is the one thing the funding upsert's +/// valve defends, so an unattributed mark would be reset to unspent the +/// next time the funding output is delivered — a rescan after restart does +/// exactly that, blind to a winner no block carries yet. +#[test] +fn claimed_input_of_a_never_held_loser_stays_spent_across_restart_and_funding_redelivery() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xFB); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x70); + let funding_txid = Txid::from_byte_array([0x71; 32]); + let funding_outpoint = OutPoint::new(funding_txid, 0); + let loser_txid = Txid::from_byte_array([0x72; 32]); + let winner_txid = Txid::from_byte_array([0x73; 32]); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + // Exactly what the adapter emits for the folded delayed-detection + + // sweep drain: no record, no `spent_utxos`, one batch vouching for the + // loser's input. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![funding_outpoint], + }], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + + { + let conn = persister.lock_conn_for_test(); + let loser_exists: bool = conn + .query_row( + "SELECT 1 FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |_| Ok(true), + ) + .optional() + .unwrap() + .unwrap_or(false); + assert!( + !loser_exists, + "the delayed stale transaction row must never be persisted" + ); + assert_eq!( + spent_in_txid(&conn, &w, &funding_outpoint), + Some(winner_txid), + "the claimed input is attributed to the winner, not merely marked" + ); + } + + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + { + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&funding_outpoint), + "the external winner's consumed input must remain spent after restart" + ); + } + + // The funding output delivered again, as an ordinary UTXO upsert. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&funding_outpoint), + "re-delivering the funding output must not lift the winner's claim" + ); + assert_eq!( + spent_in_txid(&conn, &w, &funding_outpoint), + Some(winner_txid) + ); +} + +/// The same never-held loser, but its input's funding output has not been +/// classified yet: the claim gets the same stamped placeholder a stored +/// loser's unfunded input would, and the funding upsert materialises it +/// held rather than freshly unspent. +#[test] +fn claimed_input_without_a_funding_row_gets_a_stamped_placeholder() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xFC); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x74); + let funding_txid = Txid::from_byte_array([0x75; 32]); + let funding_outpoint = OutPoint::new(funding_txid, 0); + let winner_txid = Txid::from_byte_array([0x77; 32]); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![Txid::from_byte_array([0x76; 32])], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![funding_outpoint], + }], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!(row_exists(&conn, &w, &funding_outpoint)); + assert_eq!( + spent_in_txid(&conn, &w, &funding_outpoint), + Some(winner_txid) + ); + let stamp: Option = conn + .query_row( + "SELECT winner_mined_height FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + w.as_slice(), + &blob::encode_outpoint(&funding_outpoint).unwrap()[..] + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stamp, Some(i64::from(WINNER_HEIGHT))); + } + + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + core_state::apply( + &tx, + &w, + &CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&funding_outpoint), + "the funding output materialises the placeholder as a held coin" + ); +} + +/// `spent_in_txid` of one `core_utxos` row, decoded — `None` for an +/// unattributed row or no row at all. +fn spent_in_txid(conn: &rusqlite::Connection, w: &WalletId, op: &OutPoint) -> Option { + let bytes = blob::encode_outpoint(op).unwrap(); + let raw: Option>> = conn + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w.as_slice(), &bytes[..]], + |row| row.get(0), + ) + .optional() + .unwrap(); + raw.flatten().map(|v| { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&v); + Txid::from_byte_array(arr) + }) +} + +/// The released set is applied verbatim: an outpoint it names becomes +/// spendable again, and every other input the loser claimed stays out of +/// the unspent set because the transaction that beat the loser took it. +#[test] +fn the_released_set_frees_exactly_the_inputs_it_names() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE2); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x02); + let funding_txid = Txid::from_byte_array([0x30; 32]); + let shared_input = OutPoint::new(funding_txid, 0); + let exclusive_input = OutPoint::new(funding_txid, 1); + + let loser_txid = Txid::from_byte_array([0x31; 32]); + let winner_txid = Txid::from_byte_array([0x32; 32]); + + let loser = tx_record( + loser_txid, + vec![shared_input, exclusive_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + // The winner only claimed the shared input. + let winner = tx_record( + winner_txid, + vec![shared_input], + vec![TxOut { + value: 900, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + + // Fund both inputs as ordinary unspent UTXOs, then record the loser + // spending both (mirroring the ordinary flow before it was swept). + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // Record the winner, which re-claims only the shared input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![winner], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Sanity: before the sweep, neither input shows up as unspent. + assert!(!unspent(&conn, &w).contains(&shared_input)); + assert!(!unspent(&conn, &w).contains(&exclusive_input)); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![exclusive_input], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let after = unspent(&conn, &w); + assert!( + after.contains(&exclusive_input), + "an outpoint the sweep released must come back as spendable" + ); + assert!( + !after.contains(&shared_input), + "shared input stays spent — the winner took it" + ); +} + +/// The winner does not have to reach this store at all: it can spend our +/// coin while paying only external addresses, and then no record for it is +/// ever written here. The released set still resolves both inputs +/// correctly, which is the whole reason it is carried rather than +/// recomputed from the rows on hand. +#[test] +fn an_absent_winner_still_keeps_its_own_input_spent() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE3); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x03); + let funding_txid = Txid::from_byte_array([0x40; 32]); + let taken_by_winner = OutPoint::new(funding_txid, 0); + let loser_exclusive = OutPoint::new(funding_txid, 1); + + let loser_txid = Txid::from_byte_array([0x41; 32]); + let unrecorded_winner_txid = Txid::from_byte_array([0x42; 32]); + + let loser = tx_record( + loser_txid, + vec![taken_by_winner, loser_exclusive], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + // `superseded_by` never arrives in this store; upstream still + // knows which of the loser's inputs it did not take. + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: unrecorded_winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![loser_exclusive], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let after = unspent(&conn, &w); + assert!( + !after.contains(&taken_by_winner), + "a coin the chain has already spent must not return as spendable" + ); + assert!( + after.contains(&loser_exclusive), + "the loser's own input is free, winner record or not" + ); + // Both rows survive either way — held or freed, never deleted. + assert!(row_exists(&conn, &w, &taken_by_winner)); + assert!(row_exists(&conn, &w, &loser_exclusive)); +} + +/// A round can carry both a release and a later transaction that legitimately +/// spends the freed coin: merging folds several events together, and every +/// record is applied before sweeps. `core_utxos` never records who spent a +/// row, so the release has to defer to the surviving record in the changeset +/// itself — otherwise it hands a coin the later transaction consumed back to +/// the unspent set. +#[test] +fn a_released_coin_a_surviving_record_reclaims_stays_spent() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE4); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x04); + let funding_txid = Txid::from_byte_array([0x50; 32]); + let freed_coin = OutPoint::new(funding_txid, 1); + + let loser_txid = Txid::from_byte_array([0x51; 32]); + let winner_txid = Txid::from_byte_array([0x52; 32]); + let reclaimer_txid = Txid::from_byte_array([0x53; 32]); + + let loser = tx_record( + loser_txid, + vec![OutPoint::new(funding_txid, 0), freed_coin], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let reclaimer = tx_record( + reclaimer_txid, + vec![freed_coin], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 500), + make_utxo(&addr, funding_txid, 1, 500), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // One round: the sweep frees the coin, and a surviving record in the very + // same round already spent it. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![reclaimer], + spent_utxos: vec![make_utxo(&addr, funding_txid, 1, 500)], + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![freed_coin], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + !unspent(&conn, &w).contains(&freed_coin), + "a coin a surviving record in the same round already spent must stay spent" + ); +} + +/// The reviewer scenario for the pruned-finalized-release defect: upstream +/// computes `released_outpoints` from its live records, and a chainlocked +/// spender F is pruned to a bare txid under the default +/// `keep-finalized-transactions = off` — so a loser L that arrived after the +/// pruning, reusing F's input alongside an attacker-owned one, reports F's +/// input as released when a final W beats L on the attacker input. The +/// pinned `TransactionsSwept` doc calls this unresolvable at its layer; this +/// store is the layer that CAN resolve it, because F's full record survives +/// in `core_transactions`. The release must be refused for F's coin — and +/// still honoured for a coin only the swept loser claimed, in the same +/// batch, or the guard would strand legitimately freed money. +#[test] +fn a_release_naming_a_coin_a_stored_finalized_record_claims_is_refused() { + use key_wallet::transaction_checking::transaction_context::BlockInfo; + + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x09); + let funding_txid = Txid::from_byte_array([0x90; 32]); + // F's coin — consumed on chain, must never come back. + let settled_coin = OutPoint::new(funding_txid, 0); + // Claimed only by the loser — must come free. + let losers_own_coin = OutPoint::new(funding_txid, 1); + // Attacker-owned input shared by L and W — never ours, no row. + let attacker_input = OutPoint::new(Txid::from_byte_array([0x9A; 32]), 0); + + let finalized_txid = Txid::from_byte_array([0x91; 32]); + let loser_txid = Txid::from_byte_array([0x92; 32]); + let winner_txid = Txid::from_byte_array([0x93; 32]); + + // F: chainlocked spender of `settled_coin`. Upstream keeps only its + // txid from here on; this row keeps everything. + let mut finalized = tx_record( + finalized_txid, + vec![settled_coin], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + finalized.context = TransactionContext::InChainLockedBlock(BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([0x9B; 32]), + 1_735_689_600, + )); + + // L: arrives after F's pruning, pays this wallet, reuses F's input and + // the attacker's. + let loser = tx_record( + loser_txid, + vec![settled_coin, attacker_input, losers_own_coin], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![ + make_utxo(&addr, funding_txid, 0, 400), + make_utxo(&addr, funding_txid, 1, 600), + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![finalized], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + spent_utxos: vec![make_utxo(&addr, funding_txid, 1, 600)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // W (final) conflicts with L on the attacker input alone. Upstream's + // release set — computed from live records that no longer include F — + // wrongly names F's coin alongside the loser's own. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![settled_coin, losers_own_coin], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let visible = unspent(&conn, &w); + assert!( + !visible.contains(&settled_coin), + "a released coin a stored finalized record still claims must stay spent" + ); + assert!( + visible.contains(&losers_own_coin), + "a coin only the swept loser claimed must still come free" + ); + + drop(conn); + drop(persister); + + // Restart: the guard's verdict must be what a relaunch loads — this is + // exactly where the unguarded release manufactured the double spend. + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + let visible = unspent(&conn, &w); + assert!( + !visible.contains(&settled_coin), + "the refused release must hold across a restart" + ); + assert!( + visible.contains(&losers_own_coin), + "the honoured release must hold across a restart" + ); +} + +/// The stored-claims veto is settled-evidence only: a bare mempool row must +/// not outrank an authoritative release. A mempool record is the one context +/// that can go stale forever — an evicted or abandoned mempool transaction +/// has no removal path in this store other than a later sweep, and +/// restoration does not repopulate ordinary history — so a stale claimant +/// surviving a restart must not veto the release of a coin a later loser +/// claimed, or the coin is attributed to an unrelated winner and stranded +/// durably spent: the mirror image of the wrong-release bug the veto exists +/// to stop. +#[test] +fn a_stale_mempool_claimant_does_not_veto_an_authoritative_release() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xEA); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x0A); + let funding_txid = Txid::from_byte_array([0xA0; 32]); + let coin = OutPoint::new(funding_txid, 0); + + let stale_txid = Txid::from_byte_array([0xA1; 32]); + let loser_txid = Txid::from_byte_array([0xA2; 32]); + let winner_txid = Txid::from_byte_array([0xA3; 32]); + + // M: a mempool spend of the coin, marked spent when recorded. It is + // then evicted from the network's mempool without the wallet ever + // hearing — its row simply goes stale. + let stale = tx_record( + stale_txid, + vec![coin], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![stale], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Restart: upstream's memory of M is gone for good; only the stale row + // remains. + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let mut conn = persister.lock_conn_for_test(); + + // A fresh loser claims the same coin, and an authoritative sweep later + // frees it — upstream's word, computed from the wallet it actually + // holds. + let loser = tx_record( + loser_txid, + vec![coin], + vec![TxOut { + value: 300, + script_pubkey: addr.script_pubkey(), + }], + ); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![coin], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + unspent(&conn, &w).contains(&coin), + "a stale mempool claimant must not veto an authoritative release" + ); +} + +/// The stored-claims scan is the final guard against re-crediting a +/// consumed coin, so corrupt claimant rows fail the round instead of +/// silently losing their veto: a wrong-length `txid` key and a record blob +/// whose decoded txid disagrees with its typed key are both `BlobDecode` +/// errors. +#[test] +fn a_corrupt_stored_claimant_fails_the_sweep_round_closed() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xEB); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x0B); + let funding_txid = Txid::from_byte_array([0xB0; 32]); + let coin = OutPoint::new(funding_txid, 0); + let loser_txid = Txid::from_byte_array([0xB1; 32]); + let winner_txid = Txid::from_byte_array([0xB2; 32]); + + let loser = tx_record( + loser_txid, + vec![coin], + vec![TxOut { + value: 300, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 400)], + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // A claimant row whose typed key is not 32 bytes. The blob is a real, + // decodable record so the failure is attributable to the key alone. + let honest = tx_record(Txid::from_byte_array([0xB3; 32]), vec![coin], Vec::new()); + let honest_blob = blob::encode(&honest).unwrap(); + conn.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, NULL, NULL, NULL, 1, ?3)", + params![w.as_slice(), &[0xB3u8; 31][..], &honest_blob[..]], + ) + .unwrap(); + + let sweep_cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![coin], + claimed_inputs: vec![], + }], + ..Default::default() + }; + { + let tx = conn.transaction().unwrap(); + let err = core_state::apply(&tx, &w, &sweep_cs).unwrap_err(); + assert!( + matches!( + err, + platform_wallet_storage::sqlite::error::WalletStorageError::BlobDecode { .. } + ), + "a wrong-length claimant key must fail the round closed, got {err:?}" + ); + } + + // Repair the key length but leave it disagreeing with the record's own + // txid — the typed key decides swept-loser exclusion, so the mismatch + // must fail too. + conn.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND length(txid) = 31", + params![w.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, NULL, NULL, NULL, 1, ?3)", + params![w.as_slice(), &[0xB4u8; 32][..], &honest_blob[..]], + ) + .unwrap(); + { + let tx = conn.transaction().unwrap(); + let err = core_state::apply(&tx, &w, &sweep_cs).unwrap_err(); + assert!( + matches!( + err, + platform_wallet_storage::sqlite::error::WalletStorageError::BlobDecode { .. } + ), + "a key/record txid mismatch must fail the round closed, got {err:?}" + ); + } +} + +/// A chainlocked winner may evict an InstantSend-locked loser, so a swept +/// transaction can own a row in `core_instant_locks`. Nothing ties that table +/// to `core_transactions`, so the lock has to be deleted explicitly or it +/// outlives the transaction it describes forever. +#[test] +fn sweeping_a_transaction_deletes_its_instant_lock() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE5); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x05); + let loser_txid = Txid::from_byte_array([0x60; 32]); + let loser = tx_record( + loser_txid, + vec![], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.execute( + "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, ?3)", + params![ + w.as_slice(), + AsRef::<[u8]>::as_ref(&loser_txid), + vec![0u8; 8] + ], + ) + .unwrap(); + tx.commit().unwrap(); + } + + assert_eq!( + instant_lock_count(&conn, &w, &loser_txid), + 1, + "sanity: the lock is there" + ); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: Txid::from_byte_array([0x61; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert_eq!( + instant_lock_count(&conn, &w, &loser_txid), + 0, + "the swept transaction's InstantLock must go with it" + ); +} + +fn instant_lock_count(conn: &rusqlite::Connection, w: &WalletId, txid: &Txid) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(txid)], + |row| row.get(0), + ) + .unwrap() +} + +/// Two sweeps in one round, and the later one disagrees with the earlier. +/// +/// The first frees a coin; a transaction then spends it; the second sweep +/// removes that spender but keeps the coin spent, because its own winner +/// took it. The later answer is the true one, and only replaying the batches +/// in order makes it stick — folding the release sets together leaves the +/// first "free" outliving the last "spent". +#[test] +fn a_later_sweep_keeping_a_coin_spent_overrides_an_earlier_release() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE6); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x06); + let funding_txid = Txid::from_byte_array([0x70; 32]); + let contested = OutPoint::new(funding_txid, 0); + + let first_loser = Txid::from_byte_array([0x71; 32]); + let second_loser = Txid::from_byte_array([0x72; 32]); + + let first = tx_record( + first_loser, + vec![contested], + vec![TxOut { + value: 400, + script_pubkey: addr.script_pubkey(), + }], + ); + // The transaction that took the freed coin, and that the second sweep + // removes. It is a loser too, so it is not a surviving claim. + let second = tx_record( + second_loser, + vec![contested], + vec![TxOut { + value: 300, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![first, second], + spent_utxos: vec![make_utxo(&addr, funding_txid, 0, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![ + SweepBatch { + txids: vec![first_loser], + superseded_by: Txid::from_byte_array([0x7a; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![contested], + claimed_inputs: vec![], + }, + // The second winner consumed the coin, so this sweep frees + // nothing — and that has to override the release above. + SweepBatch { + txids: vec![second_loser], + superseded_by: Txid::from_byte_array([0x7b; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }, + ], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + !unspent(&conn, &w).contains(&contested), + "the later sweep kept the coin spent, so it must not be spendable" + ); +} + +/// A loser can be persisted before its own funding output is: this store +/// only learns about a TXO through `new_utxos`/`spent_utxos`, so a spend can +/// name an outpoint `core_utxos` has never heard of. When such an input is +/// held (not released) by the sweep, `apply_sweep` has no row to update and +/// must leave a claim of its own — otherwise deleting the loser's +/// `core_transactions` row (the only place that input was ever recorded) +/// erases the claim entirely, and the funding output arriving later — even +/// after a full restart — would insert it back as a plain unspent UTXO. +#[test] +fn a_held_input_with_no_utxo_row_survives_restart_and_stays_spent_when_funded() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xE7); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x07); + let funding_txid = Txid::from_byte_array([0x80; 32]); + let unfunded_input = OutPoint::new(funding_txid, 0); + + let loser_txid = Txid::from_byte_array([0x81; 32]); + let winner_txid = Txid::from_byte_array([0x82; 32]); + + let loser = tx_record( + loser_txid, + vec![unfunded_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + // The loser's spend arrives with no prior `new_utxos`/`spent_utxos` + // for `unfunded_input` — the funding side of that outpoint has not + // been observed yet. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + assert!( + !row_exists(&conn, &w, &unfunded_input), + "sanity: no core_utxos row exists for the unfunded input yet" + ); + } + + // The sweep holds the input (it is not in `released_outpoints`), with + // nothing on hand to update. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + drop(persister); + + // Restart: a fresh persister loading the same on-disk store, exactly as + // a relaunch would see it. + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + + // The funding transaction finally arrives and hands the outpoint back + // as a UTXO — the ordinary path a rescan or late block takes. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 1_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&unfunded_input), + "the winner's claim on this input must survive the loser's deletion, \ + a restart, and the funding UTXO's own arrival" + ); +} + +/// A held-but-unfunded input's placeholder (see the test above) can itself +/// need to move again: its first winner can go on to lose a later sweep +/// while the outpoint is still unfunded. Unlike the mobile backends' pending- +/// input table, this schema has no separate relationship the placeholder +/// detaches from — `apply_sweep` always looks up the loser's inputs fresh +/// from its own `core_transactions` blob and touches `core_utxos` by +/// outpoint alone, so the second sweep finds the same placeholder row the +/// first one wrote without any chain-specific bookkeeping. This is the +/// released half: L spends P; W spends P and Q and sweeps L holding P (P is +/// still unfunded); X spends Q and sweeps W, this time releasing P. +#[test] +fn a_chained_sweep_before_funding_still_frees_an_earlier_tombstone_on_release() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE8); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x08); + let funding_txid = Txid::from_byte_array([0x90; 32]); + let unfunded_input = OutPoint::new(funding_txid, 0); + let funded_input = OutPoint::new(funding_txid, 1); + + let first_loser = Txid::from_byte_array([0x91; 32]); // L + let second_loser = Txid::from_byte_array([0x92; 32]); // W + let final_winner = Txid::from_byte_array([0x93; 32]); // X + + let l = tx_record( + first_loser, + vec![unfunded_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let w_record = tx_record( + second_loser, + vec![unfunded_input, funded_input], + vec![TxOut { + value: 900, + script_pubkey: addr.script_pubkey(), + }], + ); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + + // `funded_input` is an ordinary UTXO from the start; `unfunded_input`'s + // funding side is never observed until the very end. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 1, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // L's spend of the unfunded input arrives with no core_utxos row for it. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![l], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // First sweep: W beats L, holding the still-unfunded input. This is what + // writes the placeholder row this test is about. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![first_loser], + superseded_by: second_loser, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + row_exists(&conn, &w, &unfunded_input), + "sanity: the first sweep must have left a placeholder row" + ); + // W's own record has to be on hand for the second sweep to look its + // inputs up — the same requirement any ordinary (non-chained) sweep has. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![w_record], + spent_utxos: vec![make_utxo(&addr, funding_txid, 1, 500)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + // Second sweep: X beats W, and this time releases the input that has + // been sitting unfunded since the first sweep. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![unfunded_input], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + assert!( + !row_exists(&conn, &w, &unfunded_input), + "the chained sweep released this input while its funding TXO is \ + still unobserved — the placeholder must be deleted outright, not \ + flipped to a zero-value phantom that list_unspent would report" + ); + assert!( + !unspent(&conn, &w).contains(&funded_input), + "the second sweep's winner took the other input" + ); + + // The funding output finally classifies: with the dead claim's row gone, + // the ordinary upsert creates the coin freshly unspent with real data. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + unspent(&conn, &w).contains(&unfunded_input), + "the released coin arrives as an ordinary spendable UTXO once its \ + funding output classifies" + ); +} + +/// The held (not released) half of the chained-before-funding scenario +/// above: the second sweep keeps the still-unfunded input spent instead of +/// releasing it, and the placeholder must end up attributed to the NEW +/// winner rather than the one the second sweep just removed. Verified +/// across a full restart, then confirmed by finally funding the input — it +/// must still read as spent, and the persisted placeholder must name the +/// final winner rather than the intermediate one that no longer has a row. +#[test] +fn a_chained_sweep_before_funding_repoints_an_earlier_tombstone_to_the_new_winner() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x09); + let funding_txid = Txid::from_byte_array([0xA0; 32]); + let unfunded_input = OutPoint::new(funding_txid, 0); + + let first_loser = Txid::from_byte_array([0xA1; 32]); // L + let second_loser = Txid::from_byte_array([0xA2; 32]); // W + let final_winner = Txid::from_byte_array([0xA3; 32]); // X + + let l = tx_record( + first_loser, + vec![unfunded_input], + vec![TxOut { + value: 1_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let w_record = tx_record( + second_loser, + vec![unfunded_input], + vec![TxOut { + value: 900, + script_pubkey: addr.script_pubkey(), + }], + ); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![l], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + // First sweep: W beats L, holding the unfunded input. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![first_loser], + superseded_by: second_loser, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + // W's own record, needed by the second sweep below. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![w_record], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + + // Second sweep: X beats W, still holding the same unfunded input. + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + + // The funding transaction finally arrives. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 1_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&unfunded_input), + "the final winner's claim must survive both sweeps, the restart, \ + and the funding UTXO's own arrival" + ); + let spent_in_txid: Vec = conn + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + w.as_slice(), + &blob::encode_outpoint(&unfunded_input).unwrap()[..] + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + spent_in_txid, + AsRef::<[u8]>::as_ref(&final_winner).to_vec(), + "the placeholder must be attributed to the final winner, not the \ + intermediate one the second sweep already removed" + ); +} + +/// Confirmation, not a fix, of this round's BLOCKING finding on the mobile +/// backends' missing-row early return: there, one wallet's callback can +/// delete the shared winner row while a second wallet's detached tombstones +/// still name it, and the second wallet's own sweep of that winner then has +/// to reconcile them against a row that no longer exists. No such moment +/// exists here. `core_transactions` is keyed `(wallet_id, txid)`, so each +/// wallet sweeps its own copy of the winner and no other wallet's call can +/// have removed it first; and the tombstone is not a detached side-table row +/// but the wallet's own `core_utxos` placeholder, matched by `apply_sweep` +/// through the winner's own stored inputs — `(wallet_id, outpoint)`-scoped, +/// so the chain continues per wallet with nothing shared to lose. +/// +/// This is the reviewer's multi-wallet chained-sweep-before-funding shape +/// end to end: the same loser txid in two wallets, each claiming a +/// still-unfunded coin of its own; W beats L (both coins held as +/// placeholders); W's own record lands; X beats W, with wallet 1 releasing +/// its coin and wallet 2 holding — in that order, so wallet 1's whole chain +/// including its deletion of (its copy of) W commits before wallet 2's +/// callback runs. Each wallet's decision must land on its own coin only, and +/// each coin's eventual funding must respect it. +#[test] +fn a_multi_wallet_chained_sweep_before_funding_reconciles_each_wallets_own_tombstones() { + let (persister, _tmp, _path) = fresh_persister(); + let w1: WalletId = wid(0xF1); + let w2: WalletId = wid(0xF2); + ensure_wallet_meta(&persister, &w1); + ensure_wallet_meta(&persister, &w2); + + let addr1 = p2pkh(0x51); + let addr2 = p2pkh(0x52); + let funding_txid = Txid::from_byte_array([0x50; 32]); + // Wallet 1's coin and wallet 2's coin. Neither funding side has been + // observed in either wallet until the very end. + let p1 = OutPoint::new(funding_txid, 0); + let p2 = OutPoint::new(funding_txid, 1); + let shared_loser = Txid::from_byte_array([0x53; 32]); // L + let shared_winner = Txid::from_byte_array([0x54; 32]); // W + let final_winner = Txid::from_byte_array([0x55; 32]); // X + + // The raw transactions are the same for both wallets — a record is the + // whole on-chain transaction, inputs included — so each wallet's copy + // claims both outpoints even though only one is its own coin. + for w in [&w1, &w2] { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, w, 0, if w == &w1 { &addr1 } else { &addr2 }); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(shared_loser, vec![p1, p2], vec![])], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // First sweep in both wallets: W beats L, holding everything. Leaves + // each wallet a placeholder row per claimed outpoint, attributed to W. + for w in [&w1, &w2] { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![shared_loser], + superseded_by: shared_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // W's own record lands in both wallets, as any wallet-relevant winner's + // eventually does. + for w in [&w1, &w2] { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(shared_winner, vec![p1, p2], vec![])], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Second sweep, wallet 1 first: X beats W and wallet 1 releases its own + // coin. Its copy of W's row is deleted in the same call. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![shared_winner], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![p1], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w1, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + let gone: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w1.as_slice(), AsRef::<[u8]>::as_ref(&shared_winner)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!(gone.is_none(), "wallet 1's own copy of W is deleted"); + let w2_placeholder: Option> = conn + .query_row( + "SELECT spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w2.as_slice(), &blob::encode_outpoint(&p2).unwrap()[..]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + w2_placeholder, + Some(AsRef::<[u8]>::as_ref(&shared_winner).to_vec()), + "wallet 1's whole chained sweep, deletion included, must leave wallet 2's \ + placeholder exactly where wallet 2's own first sweep put it" + ); + } + + // Wallet 2's callback runs only now, holding its coin. Its own copy of + // W is still on hand — nothing wallet 1 committed could have removed a + // `(wallet_id, txid)`-keyed row of wallet 2's. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![shared_winner], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w2, &cs).unwrap(); + tx.commit().unwrap(); + } + + // The funding transaction finally arrives, each coin through its own + // wallet's round. + for (w, addr, vout) in [(&w1, &addr1, 0u32), (&w2, &addr2, 1u32)] { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(addr, funding_txid, vout, 1_000)], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w1).contains(&p1), + "wallet 1's released coin comes back spendable once funded" + ); + assert!( + !unspent(&conn, &w2).contains(&p2), + "wallet 2's held coin stays spent" + ); + let (spent, spent_in_txid): (i64, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w2.as_slice(), &blob::encode_outpoint(&p2).unwrap()[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(spent, 1); + assert_eq!( + spent_in_txid, + Some(AsRef::<[u8]>::as_ref(&final_winner).to_vec()), + "wallet 2's placeholder followed its own chain to the final winner, \ + driven entirely by wallet 2's own calls" + ); +} + +/// Confirmation, not a fix: the review finding that motivated the Swift/ +/// Kotlin backend changes (a shared `PersistentTransaction` row updated with +/// one wallet's `released_outpoints` before another wallet's own callback +/// gets a turn) has no analog here. `core_transactions` and `core_utxos` are +/// keyed by `(wallet_id, txid)` / `(wallet_id, outpoint)` — there is no row +/// for a "loser shared across wallets" to BE, only two wallets each holding +/// their own copy of a transaction that happens to carry the same txid. +/// `apply_sweep` re-derives every input from the loser's own stored blob and +/// matches `core_utxos` strictly within the calling wallet's rows, so one +/// wallet's sweep call cannot see, let alone touch, another wallet's copy. +/// +/// This seeds the reviewer's exact shape — the same loser txid persisted +/// independently by two wallets, each holding a different coin of its own — +/// and sweeps them in opposite decisions (wallet 1 releases its coin, +/// wallet 2 holds its own) to show neither call perturbs the other wallet's +/// row at all, regardless of which runs first. +#[test] +fn sweep_of_a_shared_loser_txid_is_independent_per_wallet() { + let (persister, _tmp, _path) = fresh_persister(); + let w1: WalletId = wid(0xE8); + let w2: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w1); + ensure_wallet_meta(&persister, &w2); + + let addr1 = p2pkh(0x31); + let addr2 = p2pkh(0x32); + let funding_txid = Txid::from_byte_array([0x30; 32]); + // Same txid recorded independently in both wallets' storage — as two + // wallets sharing one on-chain transaction each would. + let loser_txid = Txid::from_byte_array([0x33; 32]); + let winner_txid = Txid::from_byte_array([0x34; 32]); + let coin = OutPoint::new(funding_txid, 0); + + for (w, addr) in [(&w1, &addr1), (&w2, &addr2)] { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, w, 0, addr); + let tx = conn.transaction().unwrap(); + let funding = tx_record( + funding_txid, + vec![], + vec![TxOut { + value: 100_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let loser = tx_record(loser_txid, vec![coin], vec![]); + let cs = CoreChangeSet { + records: vec![funding, loser], + new_utxos: vec![make_utxo(addr, funding_txid, 0, 100_000)], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Wallet 1 sweeps its copy of the loser and releases its own coin. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![coin], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w1, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let conn = persister.lock_conn_for_test(); + let (spent, spent_in_txid): (i64, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w1.as_slice(), &blob::encode_outpoint(&coin).unwrap()[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(spent, 0, "wallet 1's release frees its own coin"); + assert!(spent_in_txid.is_none()); + let w2_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w2.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w2_loser.is_some(), + "wallet 2's own copy of the same-txid loser is a separate row, \ + untouched by wallet 1's sweep" + ); + assert!( + row_exists(&conn, &w2, &coin), + "wallet 2's coin is unaffected — it has not swept yet" + ); + } + + // Wallet 2 now sweeps its own copy of the same txid, releasing nothing. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w2, &cs).unwrap(); + tx.commit().unwrap(); + } + + let conn = persister.lock_conn_for_test(); + let w2_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w2.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w2_loser.is_none(), + "wallet 2's own sweep removes its own row" + ); + + assert!( + row_exists(&conn, &w2, &coin), + "wallet 2 released nothing, so its coin stays held with a row of its own" + ); + let (spent, spent_in_txid): (i64, Option>) = conn + .query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![w2.as_slice(), &blob::encode_outpoint(&coin).unwrap()[..]], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(spent, 1, "wallet 2's coin is held spent"); + assert_eq!( + spent_in_txid, + Some(AsRef::<[u8]>::as_ref(&winner_txid).to_vec()), + "held and attributed to wallet 2's own winner, per apply_sweep's hold contract — \ + wallet 1's earlier release of the SAME txid's other coin never touched this row" + ); +} + +/// Confirmation, not a fix, of this round's BLOCKING finding (a shared row +/// acknowledged as durably swept by one wallet's commit while a second +/// wallet's own callback is still outstanding — see the Swift/Kotlin +/// `PersistentTransaction.isGloballySwept` / `TransactionEntity. +/// isGloballySwept` flag those backends needed to add). The finding does not +/// apply here for the same structural reason as the independence test +/// above: there is no shared row for a second wallet's callback to hold +/// back in the first place, so wallet 1's own deletion has no cross-wallet +/// dependency to be durable *despite*. +/// +/// This confirms the corollary directly: wallet 1 sweeps and commits, wallet +/// 2's own callback for the same loser txid is never called again in this +/// test at all (a crash, a rejection, or it simply never coming), and the +/// persister is restarted from disk. Wallet 1's phantom output and row must +/// already be gone — nothing about their absence was waiting on wallet 2. +#[test] +fn sweep_deletion_is_durable_even_when_the_other_wallets_callback_never_arrives() { + let (persister, _tmp, path) = fresh_persister(); + let w1: WalletId = wid(0xEA); + let w2: WalletId = wid(0xEB); + ensure_wallet_meta(&persister, &w1); + ensure_wallet_meta(&persister, &w2); + + let addr1 = p2pkh(0x41); + let addr2 = p2pkh(0x42); + // Same loser txid recorded independently by both wallets, each with an + // output of its own — the "phantom money" the blocking finding is about. + let loser_txid = Txid::from_byte_array([0x43; 32]); + let winner_txid = Txid::from_byte_array([0x44; 32]); + + for (w, addr) in [(&w1, &addr1), (&w2, &addr2)] { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, w, 0, addr); + let tx = conn.transaction().unwrap(); + let loser = tx_record( + loser_txid, + vec![], + vec![TxOut { + value: 60_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let cs = CoreChangeSet { + records: vec![loser], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Only wallet 1 ever sweeps. Wallet 2's own callback for this sweep + // never arrives — this test never calls `apply` for w2 again. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w1, &cs).unwrap(); + tx.commit().unwrap(); + } + + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + + let w1_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w1.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w1_loser.is_none(), + "wallet 1's own sweep commit is durable across a restart on its own — \ + nothing about it was waiting on wallet 2's callback" + ); + assert!( + !row_exists(&conn, &w1, &OutPoint::new(loser_txid, 0)), + "wallet 1's phantom output must not survive — its deletion never depended \ + on wallet 2's callback, which never arrives in this test" + ); + + // Wallet 2 never swept, so its own independent copy legitimately still + // stands — that is correct per-wallet state, not the bug under test. + let w2_loser: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w2.as_slice(), AsRef::<[u8]>::as_ref(&loser_txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + w2_loser.is_some(), + "wallet 2's own row is untouched — it never ran its own sweep" + ); +} + +/// Confirmation, not a fix, of this round's BLOCKING finding on the mobile +/// backends (Swift `PersistentTransaction.isGloballySwept` / Kotlin +/// `TransactionEntity.isGloballySwept`): once a sweep is reversed by a +/// chainlocked return, a later-arriving record for the same txid must be +/// accepted as reinstatement rather than permanently rejected. +/// +/// That guard exists on the mobile backends only because their +/// `PersistentTransaction` / `TransactionEntity` rows are shared across +/// wallets and durably flagged the moment *any* wallet's callback observes +/// the sweep, before every wallet's own claim is known to be gone — a +/// second wallet's still-outstanding claim can keep the row physically +/// present after the first wallet's commit, which is exactly what forces a +/// flag instead of relying on row-absence. `apply_sweep` here has no such +/// row to hold onto: it is keyed `(wallet_id, txid)`, so the delete is +/// unconditional and wallet-local (`sweep_of_a_shared_loser_txid_is_ +/// independent_per_wallet` above), and a second wallet's own claim on the +/// same on-chain txid lives in an entirely separate row this wallet's sweep +/// never touches. There is therefore nothing left standing after `apply` +/// runs a sweep for the row's txid — no tombstone to clear, because there +/// is no row to protect from resurrection in the first place. A later round +/// carrying a plain record for the same `(wallet_id, txid)` is just an +/// ordinary `INSERT … ON CONFLICT DO UPDATE` into empty space, so this test +/// exercises that "reinstatement" is unconditionally already correct here, +/// across a separate `apply` call *and* a restart — the same cross-round +/// shape the mobile fix had to add tombstone-clearing for. +#[test] +fn a_record_reinstating_a_swept_txid_in_a_later_round_is_accepted_and_durable() { + let (persister, _tmp, path) = fresh_persister(); + let w: WalletId = wid(0xEC); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x51); + let txid = Txid::from_byte_array([0x53; 32]); + let winner_txid = Txid::from_byte_array([0x54; 32]); + let output = OutPoint::new(txid, 0); + + // Round 1: the transaction is recorded normally, with its own output. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let record = tx_record( + txid, + vec![], + vec![TxOut { + value: 45_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let cs = CoreChangeSet { + records: vec![record], + new_utxos: vec![make_utxo(&addr, txid, 0, 45_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Round 2, a separate `apply` call: an IS-locked conflict sweeps it — + // the row and its output are gone, same as `sweep_only_changeset_ + // deletes_loser_row_and_its_outputs` above. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + { + let conn = persister.lock_conn_for_test(); + let swept: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!(swept.is_none(), "sanity: the sweep removed the row"); + assert!( + !row_exists(&conn, &w, &output), + "sanity: its output is gone too" + ); + } + + // Round 3, yet another separate `apply` call: the wallet returns + // chainlocked and sweeps the conflict in turn — upstream's newer word, + // carried here as a plain record the same way any fresh transaction + // would arrive. Nothing on this backend needs to know it is a + // "reinstatement" rather than a first sighting. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let record = tx_record( + txid, + vec![], + vec![TxOut { + value: 45_000, + script_pubkey: addr.script_pubkey(), + }], + ); + let cs = CoreChangeSet { + records: vec![record], + new_utxos: vec![make_utxo(&addr, txid, 0, 45_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // Durable across a restart — not merely visible within the open + // connection that just wrote it. + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = persister.lock_conn_for_test(); + + let reinstated: Option> = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![w.as_slice(), AsRef::<[u8]>::as_ref(&txid)], + |row| row.get(0), + ) + .optional() + .unwrap(); + assert!( + reinstated.is_some(), + "the reinstating record must be live and durable — a later round is \ + upstream's newer word, and this backend has no tombstone standing \ + in its way" + ); + assert!( + row_exists(&conn, &w, &output), + "the reinstated transaction's own output must be live and durable too" + ); + assert!( + unspent(&conn, &w).contains(&output), + "and spendable — not left behind in some half-restored state" + ); +} + +/// A release must land even when the swept txid has no `core_transactions` +/// row of its own. A chained-sweep claim is a `core_utxos` placeholder that +/// exists independently of any transaction row, and the loser now freeing +/// it need not have one — a fatal flush error wipes a buffered round (the +/// winner's record with it) while the faulted wallet keeps persisting later +/// rounds. `apply_sweep` returns before its input loop for a missing row, +/// so if that loop were the only place releases were applied the set would +/// be silently dropped and the upsert valve would hold the placeholder's +/// `spent_in_txid` forever. +#[test] +fn a_release_applies_even_when_the_swept_txid_has_no_row() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE7); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x31); + let funding_txid = Txid::from_byte_array([0x30; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser_txid = Txid::from_byte_array([0x31; 32]); // L + let winner_txid = Txid::from_byte_array([0x32; 32]); // W — never recorded + let final_winner = Txid::from_byte_array([0x33; 32]); // X + + // Round 1: L, spending the still-unfunded P, is recorded and then swept + // by W with nothing released — leaving the held-but-absent placeholder. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(loser_txid, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + row_exists(&conn, &w, &p), + "sanity: the held claim left its placeholder" + ); + assert!(unspent(&conn, &w).is_empty()); + } + + // Round 2: W is swept in turn, releasing P — but W's own record never + // reached this store, so there is no row and no input loop to walk. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![winner_txid], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![p], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + !row_exists(&conn, &w, &p), + "the release must reach the placeholder with no loser row to walk \ + — and delete it outright, since it never materialised" + ); + } + + // The funding output finally arrives: the shed hold must let the + // upsert's valve accept the coin as unspent, with its real value. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&p), + "the funded coin stays spendable — the valve has no stale claim to defend" + ); +} + +/// One batch can sweep a parent and the child that spends its output — +/// upstream's descendant closure always removes them together, and its +/// release computation filters out outpoints whose txid is itself a loser. +/// With the parent ordered first, its pass deletes the output row; the +/// child's pass must not re-create it as a held placeholder. The +/// placeholder's `spent_in_txid` is exactly what the funding upsert's +/// valve defends, so a chainlocked reinstatement of the parent — the one +/// event that can bring the coin back — would find its genuinely unspent +/// output locked out of the restore set forever. +#[test] +fn a_batch_sweeping_parent_and_child_leaves_no_placeholder_for_the_parents_output() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE8); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x41); + let parent_txid = Txid::from_byte_array([0x40; 32]); // L + let child_txid = Txid::from_byte_array([0x41; 32]); // C + let winner_txid = Txid::from_byte_array([0x42; 32]); // W + let parent_output = OutPoint::new(parent_txid, 0); + + // L pays us and is funded; C spends L's output. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![ + tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + ), + tx_record(child_txid, vec![parent_output], vec![]), + ], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + + // The batch removes both, parent first — the ordering that deletes the + // output row before the child's pass walks its inputs. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![parent_txid, child_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + !row_exists(&conn, &w, &parent_output), + "a dead parent's output is nobody's coin — no placeholder may re-create it" + ); + } + + // The chainlocked return: L is reinstated with its output re-emitted. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + )], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "the reinstated parent's genuinely unspent output must restore — no stale \ + spent_in_txid claim may stand in its way" + ); +} + +/// The record-loss half of the co-swept rule. A parent whose record this +/// store lost (the same threat the by-outpoint release pass exists for) +/// deletes nothing in its own pass, so the child's pass must take the +/// surviving output row out of the restore set itself — leaving it +/// `spent = 0` would hand back a phantom spendable coin. And it must do +/// so by DELETING the row, not by holding it: a `spent_in_txid` claim is +/// exactly what the funding upsert's valve defends, which would lock out +/// the chainlocked reinstatement that is the one event able to bring the +/// coin back for real. +#[test] +fn a_co_swept_parent_with_no_row_still_has_its_output_removed() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xE9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x51); + let parent_txid = Txid::from_byte_array([0x50; 32]); // P — record lost + let child_txid = Txid::from_byte_array([0x51; 32]); // C + let winner_txid = Txid::from_byte_array([0x52; 32]); // W + let parent_output = OutPoint::new(parent_txid, 0); + + // P's record round was wiped, but its funded output row and C's record + // both persisted. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(child_txid, vec![parent_output], vec![])], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "sanity: the parent's output starts live" + ); + } + + // The batch sweeps both. P's pass finds no row and deletes nothing; the + // child's claim on P:0 is the only thing that can take the dead coin + // out of the unspent set. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![parent_txid, child_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + !unspent(&conn, &w).contains(&parent_output), + "a dead parent's output must not survive as a phantom spendable coin \ + just because the parent's own record was lost" + ); + assert!( + !row_exists(&conn, &w, &parent_output), + "and it must be deleted, not held — a spent_in_txid claim would lock \ + out the reinstatement below" + ); + } + + // The chainlocked return: P is reinstated with its output re-emitted, + // and nothing this sweep left behind may stand in its way. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + )], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "the reinstated parent's genuinely unspent output must restore even when \ + its record was lost at sweep time" + ); +} + +/// The second route into the co-swept-parent corner: P:0's row exists only +/// as the synthetic spent-only row `derive_spent_utxos` wrote when C's +/// record arrived IN ORDER (P's own record and funding never persisted — +/// weaker preconditions than the record-loss shape, no lost round needed). +/// The co-swept rule must treat it exactly like any other row for a dead +/// parent's output: DELETE it, never attribute it to the winner — a +/// `spent_in_txid` hold on it would survive into the upsert valve and lock +/// out P's chainlocked reinstatement forever, since no release ever names +/// a loser-funded outpoint. +#[test] +fn a_co_swept_parent_known_only_through_the_childs_spend_is_still_removed() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xEA); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x61); + let parent_txid = Txid::from_byte_array([0x60; 32]); // P — never recorded + let child_txid = Txid::from_byte_array([0x61; 32]); // C + let winner_txid = Txid::from_byte_array([0x62; 32]); // W + let parent_output = OutPoint::new(parent_txid, 0); + + // C arrives in order, spending P:0 — the spent-utxos apply writes the + // synthetic spent-only row because no funded row exists. + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(child_txid, vec![parent_output], vec![])], + spent_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + row_exists(&conn, &w, &parent_output), + "sanity: the synthetic spent-only row exists" + ); + assert!(unspent(&conn, &w).is_empty()); + } + + // The batch sweeps both; P's pass has no record to walk, so only the + // co-swept rule in C's pass can decide the synthetic row's fate. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![parent_txid, child_txid], + superseded_by: winner_txid, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let conn = persister.lock_conn_for_test(); + assert!( + !row_exists(&conn, &w, &parent_output), + "the dead parent's output must be deleted, not attributed to the winner" + ); + } + + // The chainlocked return: P reinstated with its output re-emitted must + // land spendable — nothing this sweep left behind may block the valve. + { + let mut conn = persister.lock_conn_for_test(); + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record( + parent_txid, + vec![], + vec![TxOut { + value: 5_000, + script_pubkey: addr.script_pubkey(), + }], + )], + new_utxos: vec![make_utxo(&addr, parent_txid, 0, 5_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert!( + unspent(&conn, &w).contains(&parent_output), + "the reinstated parent's output must restore even when its pre-sweep row \ + was only ever the synthetic spent-only one" + ); +} + +// ───────────────────────── tombstone collection ───────────────────────── +// +// A held-but-absent placeholder exists only for a block-context sweep and +// stores the winner's own mined height; `collect_finalized_tombstones` +// deletes it exactly when `min(chainlock_height, synced_height)` reaches +// that height — upstream's `prune_finalized_observed_spends` condition +// verbatim, no observation-age margin. These tests drive both the creation +// gate and the collector through ordinary `core_state::apply` rounds. + +fn chain_lock_at(height: u32) -> dashcore::ephemerealdata::chain_lock::ChainLock { + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::BlockHash; + dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: height, + block_hash: BlockHash::from_byte_array([0xCC; 32]), + signature: BLSSignature::from([0u8; 96]), + } +} + +/// Apply a round carrying only chain progress: processed/synced watermarks +/// and a chainlock at `height`. +fn apply_heights(conn: &mut rusqlite::Connection, w: &WalletId, height: u32) { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(height), + synced_height: Some(height), + last_applied_chain_lock: Some(chain_lock_at(height)), + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); +} + +/// `(spent, height, winner_mined_height)` of a `core_utxos` row, or `None` +/// when absent. +fn utxo_row_state( + conn: &rusqlite::Connection, + w: &WalletId, + op: &OutPoint, +) -> Option<(bool, Option, Option)> { + let bytes = blob::encode_outpoint(op).unwrap(); + conn.query_row( + "SELECT spent, height, winner_mined_height FROM core_utxos \ + WHERE wallet_id = ?1 AND outpoint = ?2", + params![w.as_slice(), &bytes[..]], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .unwrap() +} + +/// Record a loser spending `input` (no funding row exists), then sweep it +/// in the given winner context — `Some(height)` leaves the held-but-absent +/// placeholder stamped with the winner's mined height, `None` (an +/// IS-locked, unmined winner) leaves the same placeholder unstamped, which +/// the collector never touches. +fn seed_tombstone( + conn: &mut rusqlite::Connection, + w: &WalletId, + input: OutPoint, + loser: Txid, + winner: Txid, + winner_mined_height: Option, +) { + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(loser, vec![input], vec![])], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); + } + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser], + superseded_by: winner, + winner_mined_height, + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, w, &cs).unwrap(); + tx.commit().unwrap(); +} + +/// A block-context placeholder stores the WINNER'S mined height and is +/// collected exactly when `min(chainlock_height, synced_height)` reaches +/// it — upstream's `prune_finalized_observed_spends` condition verbatim, +/// no observation-age margin. At that boundary the funding transaction of +/// the outpoint (necessarily mined at or below the winner's height) has +/// been filter-scanned with no false negatives, so an unmaterialised row +/// is provably not the wallet's coin. +#[test] +fn a_never_materialised_tombstone_is_collected_at_finality_and_not_before() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF1); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x50; 32]), 0); + let loser = Txid::from_byte_array([0x51; 32]); + let winner = Txid::from_byte_array([0x52; 32]); + + let mut conn = persister.lock_conn_for_test(); + apply_heights(&mut conn, &w, 100); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); + + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "sanity: the sweep left a held, never-materialised row stamped with \ + the winner's own mined height — not any observation watermark" + ); + + // Boundary one below the winner's height: the winner's block is not + // yet inside the finality boundary, so the hold must survive. + apply_heights(&mut conn, &w, WINNER_HEIGHT - 1); + assert!( + row_exists(&conn, &w, &p), + "boundary {} has not reached the winner's height {} — the hold stays", + WINNER_HEIGHT - 1, + WINNER_HEIGHT + ); + + apply_heights(&mut conn, &w, WINNER_HEIGHT); + assert!( + !row_exists(&conn, &w, &p), + "the boundary reaching the winner's height collects the row" + ); +} + +/// The reviewer's unrelated-advancement scenario, block-context half: the +/// chainlock can run arbitrarily far ahead, but while `synced_height` sits +/// below the winner's mined height the boundary has not reached the spend +/// and the hold must survive — the funding output could still be delivered +/// by the unscanned range. It collects the moment the synced height +/// catches up. +#[test] +fn a_block_context_tombstone_outlives_unrelated_advancement_below_its_winners_height() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF7); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x62; 32]), 0); + let loser = Txid::from_byte_array([0x63; 32]); + let winner = Txid::from_byte_array([0x64; 32]); + + let mut conn = persister.lock_conn_for_test(); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); + + // Chainlocks race ahead by thousands of blocks; the filter scan has + // only reached one block short of the winner. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + synced_height: Some(WINNER_HEIGHT - 1), + last_applied_chain_lock: Some(chain_lock_at(WINNER_HEIGHT + 10_000)), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + row_exists(&conn, &w, &p), + "min(chainlock, synced) = {} is below the winner's height {} — any \ + amount of unrelated chainlock progress must not collect the hold", + WINNER_HEIGHT - 1, + WINNER_HEIGHT + ); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + synced_height: Some(WINNER_HEIGHT), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + !row_exists(&conn, &w, &p), + "the scan reaching the winner's height completes the boundary and collects" + ); +} + +/// A mempool-context sweep — an InstantSend-locked winner that has not +/// mined — preserves an UNSTAMPED tombstone for every held-but-unfunded +/// input. Under DIP-10 the IS lock alone settles those inputs: upstream's +/// `drop_conflicted_transactions` deletes the loser and retains them in +/// the account's `spent_outpoints`, a hold that carries no height and +/// that nothing can reconstruct from records once the loser is gone (the +/// winner need not be wallet-relevant). The row is that hold's only +/// durable carrier — `CORE_SWEEP_REMOVAL` requires every non-released +/// input to keep a durable spend claim before its funding TXO +/// materialises — and it is unstamped because an IS-locked winner has no +/// mining deadline, so no boundary may ever collect it; resolution is the +/// funding upsert, a later block-context re-stamp, or a release. +#[test] +fn a_mempool_context_sweep_preserves_an_unstamped_tombstone() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF8); + ensure_wallet_meta(&persister, &w); + + let mut conn = persister.lock_conn_for_test(); + // Several IS-context sweeps in a row, each with a distinct + // held-but-unfunded input. + for i in 0u8..3 { + let p = OutPoint::new(Txid::from_byte_array([0x70 + i; 32]), 0); + let loser = Txid::from_byte_array([0x80 + i; 32]); + let winner = Txid::from_byte_array([0x90 + i; 32]); + seed_tombstone(&mut conn, &w, p, loser, winner, None); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "an unmined IS-locked winner must leave a held, unstamped \ + placeholder for input #{i}" + ); + } + // Arbitrary chainlock/height advancement never collects an unstamped + // hold — two rounds, so a back-filling collector would be caught too. + apply_heights(&mut conn, &w, 1_000_000); + apply_heights(&mut conn, &w, 1_000_010); + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1 \ + AND spent = 1 AND winner_mined_height IS NULL", + params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + rows, 3, + "every unstamped hold outlasts any boundary — only funding \ + materialisation, a block-context re-stamp, or a release resolves one" + ); +} + +/// The mempool-context sweep still spend-marks a coin that HAS +/// materialised: the row carries real funding data, so holding it costs +/// nothing an attacker controls, and the winner's own record (or its +/// eventual block delivery) is the durable evidence. Its stamp stays NULL +/// — a materialised row is outside the collector's reach anyway. +#[test] +fn a_mempool_context_sweep_still_spend_marks_a_materialised_coin() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF9); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x65); + let funding_txid = Txid::from_byte_array([0x66; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser = Txid::from_byte_array([0x67; 32]); + let winner = Txid::from_byte_array([0x68; 32]); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + seed_tombstone(&mut conn, &w, p, loser, winner, None); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "a materialised coin is spend-marked by the IS-locked winner, with \ + no stamp — its funding data is real and the collector never sees it" + ); +} + +/// The reviewer's named regression: an IS-locked winner sweeps on the +/// mempool path and never mines, the app restarts, chainlocks and heights +/// advance arbitrarily, and only then is the funding output delivered. +/// Under DIP-10 the IS lock already settled that input — upstream deleted +/// the loser and retained the hold in the account's `spent_outpoints`, a +/// set rebuilt from records on load that no surviving record can +/// reconstruct (the winner need not be wallet-relevant). The unstamped +/// tombstone is therefore the claim's only durable carrier, and the +/// funding upsert must land ON it and stay spent: crediting the coin +/// would hand coin selection an outpoint the network has provably +/// consumed. This is `CORE_SWEEP_REMOVAL`'s contract verbatim — every +/// non-released input retains a durable spend claim even before its +/// funding TXO materialises. +#[test] +fn a_funding_output_arriving_after_a_mempool_sweep_and_restart_lands_spent() { + let (persister, tmp, path) = fresh_persister(); + let w: WalletId = wid(0xFA); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x69); + let funding_txid = Txid::from_byte_array([0x6A; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser = Txid::from_byte_array([0x6B; 32]); + let winner = Txid::from_byte_array([0x6C; 32]); + + { + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + seed_tombstone(&mut conn, &w, p, loser, winner, None); + } + // Restart. + drop(persister); + let cfg = SqlitePersisterConfig::new(&path); + let persister = SqlitePersister::open(cfg).expect("reopen"); + + let mut conn = persister.lock_conn_for_test(); + // Arbitrary chainlock/height advancement while the winner stays + // unmined — none of it may collect the unstamped hold. + apply_heights(&mut conn, &w, 25_000); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "the unstamped hold survives the restart and every boundary" + ); + + // The funding output is finally delivered and classified: the upsert + // materialises the row (real height, stamp stays clear) and the + // `spent_in_txid` valve keeps the coin spent. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + !unspent(&conn, &w).contains(&p), + "an input the IS-locked winner consumed must never come back \ + spendable — the sweep's claim outlives the restart" + ); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "materialised on the tombstone: real funding height, still spent, \ + permanently outside the collector's reach" + ); + drop(conn); + drop(tmp); +} + +/// Synced height alone is not finality: with no chainlock ever persisted +/// the collector must not run, mirroring upstream's "no-op until a +/// chainlock has been applied". +#[test] +fn a_tombstone_is_never_collected_without_a_persisted_chainlock() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF2); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x53; 32]), 0); + let loser = Txid::from_byte_array([0x54; 32]); + let winner = Txid::from_byte_array([0x55; 32]); + + let mut conn = persister.lock_conn_for_test(); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(100), + synced_height: Some(100), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(500), + synced_height: Some(500), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + row_exists(&conn, &w, &p), + "without a chainlock there is no finality boundary — the hold must \ + outlast any amount of synced-height progress" + ); + + // The moment a chainlock does land, the boundary exists and the + // winner's height sits inside it — the row collects immediately. + apply_heights(&mut conn, &w, 500); + assert!( + !row_exists(&conn, &w, &p), + "the first persisted chainlock supplies the boundary and the \ + winner-height stamp collects" + ); +} + +/// The genuine claim the tombstone exists for: its funding output +/// classifies, the upsert's valve keeps it spent, and materialising +/// (gaining a real `height`) takes it out of the collector's reach forever. +#[test] +fn a_materialised_claim_is_never_collected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF3); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x61); + let funding_txid = Txid::from_byte_array([0x56; 32]); + let p = OutPoint::new(funding_txid, 0); + let loser = Txid::from_byte_array([0x57; 32]); + let winner = Txid::from_byte_array([0x58; 32]); + + let mut conn = persister.lock_conn_for_test(); + derive_address(&conn, &w, 0, &addr); + apply_heights(&mut conn, &w, 100); + seed_tombstone(&mut conn, &w, p, loser, winner, Some(WINNER_HEIGHT)); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "sanity: held, unmaterialised, stamped with the winner's height" + ); + + // The funding output classifies: the valve keeps the coin spent, the + // row gains real funding data, and the stale stamp clears. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + new_utxos: vec![make_utxo(&addr, funding_txid, 0, 50_000)], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "sanity: materialised — real height, stamp cleared, still spent" + ); + + apply_heights(&mut conn, &w, 10_000); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, Some(10), None)), + "a materialised claim is the wallet's own coin held spent — no \ + boundary may ever collect it" + ); +} + +/// A held, unmaterialised row with a NULL winner height is never +/// collected. The mempool-context sweep path writes exactly this shape +/// (an IS-locked, unmined winner has no finality horizon to stamp), and +/// legacy rows read identically — either way the safe reading is to hold +/// it forever rather than guess it collectible. +#[test] +fn a_tombstone_without_a_winner_height_is_never_collected() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF4); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x59; 32]), 0); + let loser = Txid::from_byte_array([0x5A; 32]); + let winner = Txid::from_byte_array([0x5B; 32]); + + let mut conn = persister.lock_conn_for_test(); + // The real writer: an IS-context sweep of a loser whose funding row + // never arrived. + seed_tombstone(&mut conn, &w, p, loser, winner, None); + + // Two rounds, not one: a back-filling collector (the rejected design) + // would stamp the row on the first round and collect it on the second. + apply_heights(&mut conn, &w, 1_000_000); + apply_heights(&mut conn, &w, 1_000_010); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "no winner height, no proof of finality — the hold outlasts any boundary" + ); +} + +/// A chained sweep that re-points a still-unfunded claim to a new +/// block-context winner also re-stamps it with THAT winner's mined +/// height: the claim now belongs to a spend anchored at a later block, +/// and its collection horizon moves with it. +#[test] +fn a_repointed_tombstone_is_restamped_to_the_later_winners_height() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF5); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x5C; 32]), 0); + let first_loser = Txid::from_byte_array([0x5D; 32]); + let second_loser = Txid::from_byte_array([0x5E; 32]); + let final_winner = Txid::from_byte_array([0x5F; 32]); + + let mut conn = persister.lock_conn_for_test(); + seed_tombstone( + &mut conn, + &w, + p, + first_loser, + second_loser, + Some(WINNER_HEIGHT), + ); + assert_eq!( + utxo_row_state(&conn, &w, &p).and_then(|(_, _, s)| s), + Some(i64::from(WINNER_HEIGHT)), + "sanity: stamped with the first winner's mined height" + ); + + // The first winner is itself swept — by a winner mined 50 blocks + // later — still holding the unfunded input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(second_loser, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT + 50), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT + 50)))), + "the re-pointed claim is re-stamped to the later winner's mined height" + ); +} + +/// The IS-locked half of the chained case: an unmined winner re-points +/// the claim but must NOT disturb the earlier block-context stamp — +/// upstream's observed-spend entry is never retracted by an unconfirmed +/// conflict. Collection at the retained height stays sound (the funding +/// output is mined at or below the FIRST spender's height regardless of +/// who claims the coin now), so the row still collects at that boundary. +#[test] +fn a_mempool_repointed_tombstone_keeps_its_block_context_stamp() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xFB); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x6D; 32]), 0); + let first_loser = Txid::from_byte_array([0x6E; 32]); + let second_loser = Txid::from_byte_array([0x6F; 32]); + let final_winner = Txid::from_byte_array([0x71; 32]); + + let mut conn = persister.lock_conn_for_test(); + seed_tombstone( + &mut conn, + &w, + p, + first_loser, + second_loser, + Some(WINNER_HEIGHT), + ); + + // The first winner is evicted by an IS-locked, unmined conflict that + // also claims the unfunded input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(second_loser, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: None, + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "an unmined winner re-points the claim without touching the earlier \ + block-context stamp" + ); + + apply_heights(&mut conn, &w, WINNER_HEIGHT); + assert!( + !row_exists(&conn, &w, &p), + "the retained stamp still bounds the row: the funding output sits at \ + or below the first spender's height, so the boundary reaching it \ + proves delivery-or-never" + ); +} + +/// The other direction of the chained case: an UNSTAMPED hold (IS-context +/// sweep) re-pointed by a later BLOCK-context sweep gains that winner's +/// stamp — the claim now belongs to a spend anchored in a real block, so +/// it enters the collectible set and the boundary reaching the new +/// winner's height collects it. This is one of the three resolution +/// channels that bound the unstamped population. +#[test] +fn an_unstamped_tombstone_restamped_by_a_block_context_sweep_becomes_collectible() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xFC); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x72; 32]), 0); + let first_loser = Txid::from_byte_array([0x73; 32]); + let second_loser = Txid::from_byte_array([0x74; 32]); + let final_winner = Txid::from_byte_array([0x75; 32]); + + let mut conn = persister.lock_conn_for_test(); + // IS-context sweep: the hold lands unstamped. + seed_tombstone(&mut conn, &w, p, first_loser, second_loser, None); + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, None)), + "sanity: held and unstamped" + ); + + // The IS-locked first winner is itself beaten by a mined conflict + // still claiming the unfunded input. + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + records: vec![tx_record(second_loser, vec![p], vec![])], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![second_loser], + superseded_by: final_winner, + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }], + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert_eq!( + utxo_row_state(&conn, &w, &p), + Some((true, None, Some(i64::from(WINNER_HEIGHT)))), + "the block-context re-point stamps the previously unstamped hold" + ); + + apply_heights(&mut conn, &w, WINNER_HEIGHT); + assert!( + !row_exists(&conn, &w, &p), + "once stamped, the ordinary finality boundary collects the row" + ); +} + +/// Legacy shape self-heal: a zero-value released placeholder written +/// before the release path deleted them (`height` NULL, `spent = 0`) holds +/// no claim and is swept up by the collector's first pass — chainlock or +/// not — instead of reading as a phantom spendable coin forever. +#[test] +fn a_legacy_released_placeholder_is_swept_up_by_the_collector() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xF6); + ensure_wallet_meta(&persister, &w); + + let p = OutPoint::new(Txid::from_byte_array([0x60; 32]), 0); + let mut conn = persister.lock_conn_for_test(); + // Plant the pre-fix shape directly — the current release path can no + // longer produce it. + { + let bytes = blob::encode_outpoint(&p).unwrap(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, X'', NULL, 0, 0, NULL)", + params![w.as_slice(), &bytes[..]], + ) + .unwrap(); + } + assert!( + unspent(&conn, &w).contains(&p), + "sanity: the legacy phantom" + ); + + { + let tx = conn.transaction().unwrap(); + let cs = CoreChangeSet { + last_processed_height: Some(100), + synced_height: Some(100), + ..Default::default() + }; + core_state::apply(&tx, &w, &cs).unwrap(); + tx.commit().unwrap(); + } + assert!( + !row_exists(&conn, &w, &p), + "the first height-carrying round deletes the claimless leftover" + ); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 2ff19d8235e..3f1061a9f93 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -66,8 +66,8 @@ use crate::wallet::identity::{ /// record vecs newest-wins (by txid for the wallet-level `records`, by /// `(txid, account)` for `account_records` — see /// [`fold_same_txid_records`]), uses monotonic-max for the height -/// watermarks, `extend` for the utxo vecs, and last-write-wins for the -/// IS-lock map. +/// watermarks, `extend` for the utxo vecs, last-write-wins for the +/// IS-lock map, and ordered append for [`Self::sweeps`]. /// /// # Why a projection instead of the upstream type /// @@ -133,6 +133,32 @@ pub struct CoreChangeSet { /// without a follow-up read. pub spent_utxos: Vec, + /// Inputs proven spent by a detection whose transaction the live + /// wallet no longer holds, keyed by that transaction's txid: the event + /// bridge finds the record already swept (or pruned) by the time its + /// queued detection is projected, and refuses to restore the stale + /// row — but the record's own input details are the only evidence + /// left of what it consumed. Only the event bridge fills this, and the + /// adapter settles it before a changeset reaches a persister — + /// attributing each entry to the sweep batch that removed its txid (see + /// [`SweepBatch::claimed_inputs`]) and demoting what no sweep names to + /// plain [`spent_utxos`](Self::spent_utxos) — so a persister always + /// receives it empty and need not read it. Never journaled: it exists + /// only between projection and settlement. + #[cfg_attr(feature = "serde", serde(skip))] + pub dropped_record_spends: BTreeMap>, + + /// Raw inputs of a transaction the live wallet no longer holds, keyed + /// by that transaction's txid. Unlike [`Self::dropped_record_spends`], + /// this includes inputs whose funding output was unknown when the + /// transaction was detected and therefore had no `InputDetail` from + /// which to reconstruct a [`Utxo`]. The adapter attaches these outpoints + /// to the matching sweep's [`SweepBatch::claimed_inputs`] so every + /// persister can keep the winner's hold even if the funding output only + /// materializes later. Never journaled or passed to a persister. + #[cfg_attr(feature = "serde", serde(skip))] + pub dropped_record_input_claims: BTreeMap>, + /// UTXOs to add — outputs created by records in this batch that pay /// to one of our addresses (i.e. `OutputRole::Received` or /// `OutputRole::Change` per the upstream `TransactionRecord`). @@ -231,6 +257,105 @@ pub struct CoreChangeSet { /// lower height never overwrites a higher one — chain locks are /// strictly forward-advancing per upstream's contract). pub last_applied_chain_lock: Option, + + /// Sweeps this batch carries, in the order the wallet emitted them. + /// + /// The one subtractive part of this type. Every other field is additive, + /// which is exactly why this one has to exist: a persister that only ever + /// appends keeps the dead rows and replays them on the next load, + /// re-creating a balance the wallet has already corrected. + /// + /// Kept as ordered batches rather than folded into one removal list plus + /// one release set. Each sweep describes the wallet at the moment it + /// fired, and those descriptions can disagree: an early sweep frees a + /// coin, something later spends it, and a later sweep removes that + /// spender while keeping the coin spent because its own winner took it. + /// Union the release sets and the first answer outlives the last one that + /// is actually true. Applied in order, each batch corrects the one before + /// it, which is what the wallet itself did. + /// `serde(default)`: this field postdates the serialized representation, + /// so a payload written before it necessarily omits it. An empty vec is + /// the exact backward-compatible reading — a changeset from then could + /// not have carried a sweep. + #[cfg_attr(feature = "serde", serde(default))] + pub sweeps: Vec, +} + +/// One `TransactionsSwept` event: the transactions it removed, the +/// transaction that beat them, and the coins its removal actually freed. +/// +/// The grouping is what makes ordering expressible. `released_outpoints` is +/// only true relative to the wallet as this event saw it, so it belongs with +/// the removals it came from rather than in a set shared with every other +/// sweep in the batch. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SweepBatch { + /// The removed transactions. Their rows and every UTXO they created go. + pub txids: Vec, + /// The transaction whose arrival settled the inputs — final, and + /// therefore the reason the removed ones can never confirm. Not + /// necessarily wallet-relevant: it can pay entirely to outside addresses + /// and still sweep, which is why it cannot be looked up to work out what + /// it took. + pub superseded_by: Txid, + /// Mined height of `superseded_by` when the sweep was triggered by its + /// arrival in a block; `None` when it was triggered by an + /// InstantSend-locked winner still waiting to be mined (upstream's only + /// two triggers — an unlocked mempool arrival never sweeps). + /// + /// This is the winner's finality context, straight from the event: the + /// winner need not be wallet-relevant, so no persister can look its + /// height up in its own records. A held-but-unfunded input is mirrored + /// as a durable placeholder in EITHER case; this field decides the + /// placeholder's lifetime. `Some` stamps the winner's own block height + /// — the projection of upstream's `observed_spent_outpoints` — and the + /// placeholder is collectible once `min(chainlock_height, + /// synced_height)` reaches it, exactly upstream's + /// `prune_finalized_observed_spends` boundary. `None` (IS-locked + /// winner, unmined) leaves the placeholder UNSTAMPED and never + /// collectible: under DIP-10 the lock alone settles the input — + /// upstream retains it in the account's `spent_outpoints`, a hold with + /// no height that no record survives to rebuild — and an IS-locked + /// winner has no mining deadline, so no watermark can ever prove the + /// funding output delivered-or-never. An unstamped placeholder + /// resolves only through proof: funding materialisation, a later + /// block-context sweep's re-stamp, or a release. + /// + /// `serde(default)`: a journaled payload written before this field + /// existed reads back as `None` — the conservative reading (no new + /// placeholder, existing stamps kept). + #[cfg_attr(feature = "serde", serde(default))] + pub winner_mined_height: Option, + /// Of the inputs those removed transactions claimed, the ones that came + /// free — no surviving transaction spends them too. Everything else they + /// claimed was taken by `superseded_by` and stays spent. + pub released_outpoints: Vec, + /// Inputs the removed transactions claimed, as far as the round's own + /// events prove it, for a persister that never stored the removed + /// transaction. A persister ordinarily holds every input of a row it + /// deletes, and `released_outpoints` alone tells it which came free — + /// but a transaction can be swept before its own detection reaches + /// persistence, and then no store has a row to read inputs from. The + /// detection event still carried them; the adapter attaches them here + /// so the sweep can settle them exactly as it would a deleted row's + /// inputs: released ones come free, every other one is held spent and + /// attributed to `superseded_by`, with a durable placeholder when the + /// funding output has not materialised. Usually empty, and may repeat + /// inputs of a row the persister does hold — settling an input twice is + /// idempotent. + /// + /// Not keyed by loser: when a later record reinstates one of `txids` + /// (see the `Merge` impl) its claimed inputs stay on the batch under + /// `superseded_by`. The coin really is spent — by the reinstated + /// transaction — so nothing is handed back wrongly; only the recorded + /// spender is the winner that has since lost, which the reinstated + /// record's own spend evidence supersedes when it confirms. + /// + /// `serde(default)`: a journaled payload written before this field + /// existed reads back as empty. + #[cfg_attr(feature = "serde", serde(default))] + pub claimed_inputs: Vec, } /// Highest-used derivation index per pool slot for one account, as @@ -494,6 +619,29 @@ fn context_rank(context: &key_wallet::transaction_checking::TransactionContext) impl Merge for CoreChangeSet { fn merge(&mut self, other: Self) { + // A record arriving after a sweep that removed the same transaction + // reinstates it, and every persister writes records before replaying + // sweeps — so without this the sweep would delete a row the wallet + // has since brought back. Reachable through IS-lock precedence: an + // unconfirmed transaction is swept when an IS-locked conflict lands, + // then returns chainlocked and sweeps that conflict in turn. + // + // The release set stays as it is. It is the aggregate for every loser + // in the batch, so dropping it when one of them is reinstated would + // discard coins freed by the losers that are still going. Entries + // belonging to the reinstated transaction are inert on every backend: + // each scopes its release to the remaining losers' own inputs, or + // withholds any outpoint a surviving record claims — and the + // reinstating record is exactly such a claim. + if !other.records.is_empty() && !self.sweeps.is_empty() { + let reinstated: std::collections::HashSet = + other.records.iter().map(|record| record.txid).collect(); + for batch in &mut self.sweeps { + batch.txids.retain(|txid| !reinstated.contains(txid)); + } + self.sweeps.retain(|batch| !batch.txids.is_empty()); + } + // Records: coalesce by txid, NEWEST-WINS. // // The event bridge already folded each event's per-account @@ -516,6 +664,18 @@ impl Merge for CoreChangeSet { (r.txid, r.account_type) }); self.spent_utxos.extend(other.spent_utxos); + for (txid, utxos) in other.dropped_record_spends { + self.dropped_record_spends + .entry(txid) + .or_default() + .extend(utxos); + } + for (txid, outpoints) in other.dropped_record_input_claims { + self.dropped_record_input_claims + .entry(txid) + .or_default() + .extend(outpoints); + } self.new_utxos.extend(other.new_utxos); // IS-lock map: last-write-wins per txid. A second IS-lock for @@ -609,12 +769,21 @@ impl Merge for CoreChangeSet { .or_default() .merge_max(indexes); } + + // Sweeps: appended, never folded. Order is the whole point — a later + // batch's decision to keep a coin spent has to survive an earlier + // batch's decision to free it, and only replaying them in sequence + // preserves that. + self.sweeps.extend(other.sweeps); } fn is_empty(&self) -> bool { self.records.is_empty() + && self.sweeps.is_empty() && self.account_records.is_empty() && self.spent_utxos.is_empty() + && self.dropped_record_spends.is_empty() + && self.dropped_record_input_claims.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() && self.last_processed_height.is_none() @@ -626,6 +795,64 @@ impl Merge for CoreChangeSet { } } +impl CoreChangeSet { + /// Settle [`dropped_record_spends`](Self::dropped_record_spends) against + /// this changeset's sweeps and return whatever no sweep here names. + /// + /// An entry whose txid a batch in `sweeps` removed becomes that batch's + /// [`SweepBatch::claimed_inputs`]: the sweep is what settles those + /// inputs, and the winner it names is the attribution every persister + /// keeps a held input under. The rest are handed back to the caller — + /// the transaction may have been chain-locked and pruned rather than + /// swept (its spends are real, and already persisted by the events + /// that confirmed it), or its sweep may still be queued for a later + /// drain — and the caller decides how long to keep waiting for a sweep + /// before writing them as unattributed spends. + pub(crate) fn settle_dropped_record_spends(&mut self) -> BTreeMap> { + let mut pending = std::mem::take(&mut self.dropped_record_spends); + if pending.is_empty() { + return pending; + } + for batch in &mut self.sweeps { + for txid in &batch.txids { + let Some(utxos) = pending.remove(txid) else { + continue; + }; + for utxo in utxos { + if !batch.claimed_inputs.contains(&utxo.outpoint) { + batch.claimed_inputs.push(utxo.outpoint); + } + } + } + } + pending + } + + /// Attach raw input claims from dropped records to the sweep that + /// removed each record and return claims whose sweep is not in this + /// changeset. This is the funding-output-independent counterpart of + /// [`Self::settle_dropped_record_spends`]. + pub(crate) fn settle_dropped_record_input_claims(&mut self) -> BTreeMap> { + let mut pending = std::mem::take(&mut self.dropped_record_input_claims); + if pending.is_empty() { + return pending; + } + for batch in &mut self.sweeps { + for txid in &batch.txids { + let Some(outpoints) = pending.remove(txid) else { + continue; + }; + for outpoint in outpoints { + if !batch.claimed_inputs.contains(&outpoint) { + batch.claimed_inputs.push(outpoint); + } + } + } + } + pending + } +} + // --------------------------------------------------------------------------- // Identities // --------------------------------------------------------------------------- @@ -1246,32 +1473,47 @@ impl Merge for AssetLockChangeSet { // swift-sdk `persistAssetLocks`), making the store order of // racing snapshots immaterial. for (out_point, entry) in other.asset_locks { - if entry.status == AssetLockStatus::Consumed { - // A Consumed write supersedes any earlier-folded - // tombstone for the outpoint — Consumed rows are - // deliberately retained for historical lookup (see the - // variant doc), so the terminal write wins over a stale - // removal exactly as it wins over a stale status. - self.removed.remove(&out_point); - } else if let Some(existing) = self.asset_locks.get(&out_point) { - if existing.status == AssetLockStatus::Consumed { - continue; + if entry.status != AssetLockStatus::Consumed { + if let Some(existing) = self.asset_locks.get(&out_point) { + if existing.status == AssetLockStatus::Consumed { + continue; + } } } + // Every ACCEPTED upsert supersedes an earlier-folded tombstone + // for its outpoint, not just a Consumed one. Sweeps are a + // removal producer now (`remove_tracked_asset_locks_for_swept`), + // and a swept funding transaction can return chainlocked in the + // same folded drain — the reinstating record re-inserts the + // entry through reconstruction at a non-Consumed status, and + // letting the sweep's tombstone ride along would have the store + // delete the row it just reinstated (SQLite applies upserts + // before removals) while the in-memory wallet keeps it. This is + // the asset-lock mirror of `CoreChangeSet::merge`'s + // reinstated-txid retraction. For Consumed the same line also + // covers the historical rule: the terminal write wins over a + // stale removal exactly as it wins over a stale status. + self.removed.remove(&out_point); self.asset_locks.insert(out_point, entry); } - // Tombstones folded after a Consumed upsert are dropped for the - // same reason. The only removal emitter (`untrack_asset_lock`) - // fires exclusively for Built rows whose broadcast was - // definitively rejected, so a Consumed/removed pair for one - // outpoint has no legitimate producer — this is defense in - // depth matching the upsert guard. + // Tombstones folded after a Consumed upsert are dropped — Consumed + // rows are deliberately retained for historical lookup (see the + // variant doc). Any other pending upsert is dropped WITH the + // tombstone landing: a removal is upstream's newer word for the + // outpoint (a lock tracked and then swept, or a Built row rejected + // at broadcast, inside one fold), and carrying the dead upsert + // alongside the tombstone would make every store's correctness + // depend on applying upserts before removals. Together with the + // retraction above this keeps the invariant every backend relies + // on: a merged changeset never carries both an upsert and a + // tombstone for the same outpoint. for out_point in other.removed { let consumed = self .asset_locks .get(&out_point) .is_some_and(|entry| entry.status == AssetLockStatus::Consumed); if !consumed { + self.asset_locks.remove(&out_point); self.removed.insert(out_point); } } @@ -2097,6 +2339,63 @@ impl Merge for PlatformWalletChangeSet { } } +#[cfg(all(test, feature = "serde"))] +mod serde_compat_tests { + use super::*; + + /// A changeset serialized before `sweeps` existed must still load. The + /// field postdates the representation, so an older payload simply omits + /// it — and an empty vec is the exact reading, since nothing back then + /// could have carried a sweep. Without `serde(default)` the whole + /// deserialization fails and every pre-sweep payload becomes unreadable. + #[test] + fn a_pre_sweep_payload_deserializes_with_no_sweeps() { + let json = r#"{ + "records": [], + "spent_utxos": [], + "new_utxos": [], + "instant_locks_for_non_final_records": {}, + "last_processed_height": 1000, + "synced_height": 900, + "account_highest_used": {}, + "last_applied_chain_lock": null + }"#; + + let cs: CoreChangeSet = + serde_json::from_str(json).expect("a pre-sweep payload must still deserialize"); + assert!(cs.sweeps.is_empty()); + assert_eq!(cs.last_processed_height, Some(1000)); + assert_eq!(cs.synced_height, Some(900)); + } + + /// A sweep batch serialized before `claimed_inputs` existed reads back + /// with none: nothing back then could have vouched for an input. + #[test] + fn a_sweep_payload_without_claimed_inputs_deserializes_with_none() { + let json = r#"{ + "records": [], + "spent_utxos": [], + "new_utxos": [], + "instant_locks_for_non_final_records": {}, + "last_processed_height": null, + "synced_height": null, + "account_highest_used": {}, + "last_applied_chain_lock": null, + "sweeps": [{ + "txids": ["1111111111111111111111111111111111111111111111111111111111111111"], + "superseded_by": "2222222222222222222222222222222222222222222222222222222222222222", + "released_outpoints": [] + }] + }"#; + + let cs: CoreChangeSet = + serde_json::from_str(json).expect("a pre-claimed-inputs sweep must deserialize"); + assert_eq!(cs.sweeps.len(), 1); + assert!(cs.sweeps[0].claimed_inputs.is_empty()); + assert!(cs.sweeps[0].winner_mined_height.is_none()); + } +} + #[cfg(test)] mod tests { use super::*; @@ -2216,10 +2515,38 @@ mod tests { folded.asset_locks[&outpoint].status, AssetLockStatus::Consumed ); - // …and a legitimate removal (rejected Built row) still folds. + // …and a legitimate removal (rejected Built row, or a sweep of the + // funding tx) still folds — taking the now-dead upsert with it, so + // no store ever sees an upsert/tombstone pair whose outcome would + // hinge on which it applies first. let mut folded = cs_with(AssetLockStatus::Built); folded.merge(removal()); assert!(folded.removed.contains(&outpoint)); + assert!( + !folded.asset_locks.contains_key(&outpoint), + "a tombstone folding in must not leave the dead upsert beside it" + ); + + // The coalesced sweep-then-chainlocked-reinstatement fold: the + // sweep removes the tracked entry and contributes a tombstone, then + // the reinstating record re-inserts through reconstruction at a + // non-Consumed status — in the SAME drain. The accepted upsert must + // cancel the earlier tombstone (the asset-lock mirror of + // `CoreChangeSet::merge`'s reinstated-txid retraction); otherwise + // SQLite — upserts before removals — deletes the row it just + // reinstated while the in-memory wallet keeps it, and the durable + // tracked lock is gone after restart even though its funding + // transaction survived. + let mut folded = removal(); + folded.merge(cs_with(AssetLockStatus::RecoveredFromChain)); + assert!( + folded.removed.is_empty(), + "a reinstating reconstruction must cancel the folded sweep tombstone" + ); + assert_eq!( + folded.asset_locks[&outpoint].status, + AssetLockStatus::RecoveredFromChain + ); } #[test] diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 74e97dee742..5f52084359a 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -37,6 +37,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use dashcore::blockdata::transaction::{txout::TxOut, OutPoint}; +use dashcore::Txid; use key_wallet::account::AccountType; use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, AddressState}; use key_wallet::managed_account::transaction_record::{OutputRole, TransactionRecord}; @@ -51,9 +52,10 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use crate::changeset::changeset::{ - AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, + AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, SweepBatch, }; use crate::changeset::merge::Merge; +use crate::changeset::persistence_capabilities::PersistenceCapabilities; use crate::changeset::traits::PlatformWalletPersistence; use crate::wallet::asset_lock::sync::reconstruction; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -75,10 +77,24 @@ use crate::wallet::platform_wallet::PlatformWalletInfo; /// Folding every event *already buffered* in the channel into one changeset /// per wallet collapses a burst of N events into a single store, so the /// drain keeps pace with the producer at projection speed. This is -/// exactly the fold [`Merge`] was specified for — `CoreChangeSet` merging -/// is commutative and associative, and its doc comment already anticipates -/// "a flush can fold multiple events together (TransactionDetected + -/// BlockProcessed for the same wallet over a sync round)". +/// exactly the fold [`Merge`] was specified for — an ORDERED left fold in +/// channel-arrival order. `CoreChangeSet` merging is associative but NOT +/// commutative, so regrouping the fold is safe but reordering or +/// parallelizing it is not: sweep-aware merging deliberately depends on +/// operand order in two ways. A record arriving after a sweep of the same +/// txid retracts that sweep (reinstatement), while a sweep arriving after +/// the record survives the merge and deletes the row at apply time — +/// swapping the operands swaps which of those happens. And sweep batches +/// append in emission order because each release set is only true of the +/// wallet as that sweep saw it, so a later batch keeping a coin spent must +/// replay after the earlier batch that freed it. (The IS-lock map's +/// last-write-wins and the chain-lock equal-height tie-break also take the +/// later operand.) A reordered fold can therefore persist a different +/// spend decision, not just a differently-arranged changeset. The doc +/// comment on [`Merge`] states the same contract and already anticipates +/// this fold: "a flush can fold multiple events together +/// (TransactionDetected + BlockProcessed for the same wallet over a sync +/// round)". /// /// The cap bounds the worst-case size of a single merged changeset (and /// hence one Room transaction), and keeps a saturated producer from @@ -310,6 +326,10 @@ async fn run_wallet_event_adapter

( // One-shot latch so the hard "watermark frozen" line hits logcat exactly // once per session rather than once per faulted batch. let freeze_logged = Arc::new(AtomicBool::new(false)); + // Input evidence of records the live wallet had already dropped when + // their detection was projected, whose sweep has not been drained yet + // (see `settle_dropped_record_spends` below for its lifetime). + let mut carried_record_spends: CarriedSpendsByWallet = BTreeMap::new(); loop { // Block for the first event of a batch. Everything already sitting in @@ -368,6 +388,33 @@ async fn run_wallet_event_adapter

( } } } + // Whether this drain consumed everything that was queued when it + // ran, as opposed to stopping at the fold limit with more behind it. + // Decides how long unpaired spend evidence is kept (see + // `settle_dropped_record_spends`). + let drained_to_empty = closed || folded < ADAPTER_STORE_BATCH_LIMIT; + + for (wallet_id, wallet_batch) in batch.iter_mut() { + settle_dropped_record_spends( + *wallet_id, + &mut wallet_batch.core, + &mut carried_record_spends, + ); + } + if drained_to_empty { + // Evidence still unpaired after the channel ran dry has nothing + // left to wait for: only what THIS drain produced is kept, for + // one more drain (see the helper's doc for the race it covers). + carried_record_spends.retain(|_, entries| { + entries.retain(|_, carried| carried.fresh); + !entries.is_empty() + }); + } + for entries in carried_record_spends.values_mut() { + for carried in entries.values_mut() { + carried.fresh = false; + } + } // Commit the folded batch. The channel is lossless, so a watermark is // held back only by the fail-closed backstops around this call: a @@ -570,6 +617,119 @@ 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. +/// Spend evidence of one dropped record, held back by the adapter until a +/// sweep names its txid. `fresh` marks evidence the most recent drain +/// produced; see [`settle_dropped_record_spends`] for its lifetime. +struct CarriedRecordSpends { + utxos: Vec, + input_claims: Vec, + fresh: bool, +} + +type CarriedSpendsByWallet = BTreeMap>; + +/// Attribute a drain's dropped-record spend evidence to the sweeps that +/// removed those records, and decide what to do with evidence no sweep in +/// the drain names. +/// +/// A detection projected after the live wallet already swept its +/// transaction carries the loser's input details but no row (see the +/// `TransactionDetected` arm of [`build_core_changeset`]). The sweep event +/// that removed it was queued the moment the sweep ran — before the +/// projection could observe the record gone — so it sits behind the +/// detection in the channel and usually folds into the same drain; +/// `settle_dropped_record_spends` pairs the two by txid and the inputs +/// travel with the batch as [`SweepBatch::claimed_inputs`], which every +/// persister settles under the winner's attribution — the only form of the +/// claim that survives the funding output being delivered again later. +/// +/// Evidence no sweep in this drain names is written as ordinary +/// [`CoreChangeSet::spent_utxos`] right away and kept in `carried` for later +/// drains of the same wallet. The interim write reaches the SQLite store +/// (whose `apply` marks the coin spent, unattributed, until the sweep's +/// attribution lands); the FFI hosts derive spends from records alone and +/// see nothing until the pairing, so for them the carry IS the fix. How +/// long the evidence is kept follows from where the sweep can be: it was +/// already queued when the projection ran, so it is at most one full +/// channel behind. Evidence therefore survives every drain that stops at +/// the fold limit (`ADAPTER_STORE_BATCH_LIMIT`, a backlog with more behind +/// it) and is dropped once a drain has run the channel dry without pairing +/// it — except that the drain which produced it always carries it one +/// drain further, covering the window between a sweep dropping the +/// manager's write lock and enqueueing its event, during which the +/// projection can already see the record gone. What is dropped is the +/// other way a record vanishes: chain-locked and pruned, its spends +/// already persisted by the events that confirmed it, with no sweep ever +/// coming. The caller applies that policy after settling every wallet in +/// the drain; this function only pairs, writes and re-carries. +fn settle_dropped_record_spends( + wallet_id: WalletId, + core: &mut CoreChangeSet, + carried: &mut CarriedSpendsByWallet, +) { + let fresh_spends = std::mem::take(&mut core.dropped_record_spends); + let fresh_claims = std::mem::take(&mut core.dropped_record_input_claims); + let previous = carried.remove(&wallet_id).unwrap_or_default(); + let mut still_carried: BTreeMap = BTreeMap::new(); + if !previous.is_empty() { + core.dropped_record_spends = previous + .iter() + .map(|(txid, carried)| (*txid, carried.utxos.clone())) + .collect(); + core.dropped_record_input_claims = previous + .iter() + .map(|(txid, carried)| (*txid, carried.input_claims.clone())) + .collect(); + // Already written as unattributed spends by the drain that produced + // them; pair what this drain's sweeps name, keep the rest waiting. + let mut unpaired_spends = core.settle_dropped_record_spends(); + let mut unpaired_claims = core.settle_dropped_record_input_claims(); + for (txid, carried) in previous { + let utxos = unpaired_spends.remove(&txid).unwrap_or_default(); + let input_claims = unpaired_claims.remove(&txid).unwrap_or_default(); + if !utxos.is_empty() || !input_claims.is_empty() { + still_carried.insert( + txid, + CarriedRecordSpends { + utxos, + input_claims, + fresh: carried.fresh, + }, + ); + } + } + } + core.dropped_record_spends = fresh_spends; + core.dropped_record_input_claims = fresh_claims; + let mut unpaired_spends = core.settle_dropped_record_spends(); + for (txid, input_claims) in core.settle_dropped_record_input_claims() { + let utxos = unpaired_spends.remove(&txid).unwrap_or_default(); + core.spent_utxos.extend(utxos.iter().cloned()); + still_carried.insert( + txid, + CarriedRecordSpends { + utxos, + input_claims, + fresh: true, + }, + ); + } + for (txid, utxos) in unpaired_spends { + core.spent_utxos.extend(utxos.iter().cloned()); + still_carried.insert( + txid, + CarriedRecordSpends { + utxos, + input_claims: vec![], + fresh: true, + }, + ); + } + if !still_carried.is_empty() { + carried.insert(wallet_id, still_carried); + } +} + fn commit_batch

( persister: &P, batch: BTreeMap, @@ -583,13 +743,52 @@ where P: PlatformWalletPersistence + ?Sized, { let mut diag = BatchDiagnostics::new(folded, batch.len()); - for ( - wallet_id, - WalletBatch { - mut core, - asset_locks, - }, - ) in batch + for (wallet_id, wallet_batch) in batch { + commit_wallet( + persister, + wallet_id, + wallet_batch, + &mut diag, + CommitFaults { + state: fault, + sync_fault, + freeze_logged, + }, + settled, + ); + } + diag +} + +/// Mutable fault state and the two session latches updated when a wallet +/// persistence round cannot be trusted. +struct CommitFaults<'a> { + state: &'a mut AdapterFaultState, + sync_fault: &'a AtomicBool, + freeze_logged: &'a AtomicBool, +} + +/// Commit one wallet's folded changeset — the per-wallet unit of +/// [`commit_batch`]. +fn commit_wallet

( + persister: &P, + wallet_id: WalletId, + wallet_batch: WalletBatch, + diag: &mut BatchDiagnostics, + faults: CommitFaults<'_>, + settled: &mut Vec, +) where + P: PlatformWalletPersistence + ?Sized, +{ + let CommitFaults { + state: fault, + sync_fault, + freeze_logged, + } = faults; + let WalletBatch { + mut core, + asset_locks, + } = wallet_batch; { // Hold this wallet's durable watermark at the last fully persisted // height once it has faulted. Records/UTXOs still persist — only the @@ -612,11 +811,40 @@ where // SyncHeightAdvanced for an unknown wallet, empty BlockProcessed, a // watermark-only batch stripped by the fault guard above, etc. — // nothing to persist. Skip the round-trip. - continue; + return; } // The height this changeset OFFERS to the store. It is counted as // persisted only in the `Ok` arm below. let offered_height = core.synced_height; + + // Sweeps reach an FFI host only through the persistence extension's + // size-negotiated sweep callback, and Rust never calls a slot the + // host's declared `struct_size` did not prove — so a persister + // predating that slot (an old C host, or a Kotlin subclass that + // never overrode `onWalletChangesetTransactionsSwept`) processes the + // rest of the round normally and returns success without ever + // seeing `core.sweeps` at all. `store()` coming back `Ok` in that + // case proves nothing about whether the removal actually happened, + // so it is checked separately from the result below rather than + // folded into it. + let sweep_removal_unsupported = !core.sweeps.is_empty() + && !persister + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL); + if sweep_removal_unsupported { + // Strip the watermark from THIS round, not just later ones. The + // adapter folds whatever is buffered, so a `TransactionsSwept` + // and a following `SyncHeightAdvanced` land in one changeset — + // and `synced_height` lives in the unchanged prefix such a + // persister does read. Letting it through would commit a height + // that claims blocks are scanned while the removal those blocks + // implied never landed, and the fault below cannot retract a + // watermark the backend has already made durable. `offered_height` + // keeps the original so the rejection is still diagnosed as a + // withheld advance rather than as a round that carried none. + core.synced_height = None; + } + let cs = PlatformWalletChangeSet { core: Some(core), // Tracked-asset-lock rows reconstructed from this drain's @@ -627,14 +855,43 @@ where ..PlatformWalletChangeSet::default() }; let store_result = persister.store(wallet_id, cs); - // Recorded only once the store has RETURNED, and whether it accepted - // or rejected — both are answers. A wallet whose store panicked never - // reaches this line, and one the loop never got to is never pushed at - // all, so what is missing from `settled` is exactly the set whose - // outcome the caller cannot reason about. See the `JoinError` arm in - // `run_wallet_event_adapter`. + // A returned result, success or failure, settles this wallet's outcome. + // A panic never reaches this line, leaving the caller to freeze the + // exact unknown-outcome set. settled.push(wallet_id); match store_result { + Ok(()) if sweep_removal_unsupported => { + // The write nominally succeeded, but a backend that never + // attested `CORE_SWEEP_REMOVAL` is not known to have applied + // the one subtractive part of this round — reporting it + // durable would let the swept loser return at the next + // `load()`. Fault exactly like a rejection: the next scan + // re-emits the sweep and the idempotent removal is retried + // against (hopefully, by then) a capable backend. + if fault_and_freeze( + diag, + offered_height, + fault, + sync_fault, + wallet_id, + is_faulted, + freeze_logged, + ) { + log::error!( + "SYNC WATERMARK FROZEN: persister for wallet {} does not advertise \ + CORE_SWEEP_REMOVAL but this round swept one or more transactions; a \ + removal must never be reported durable to a backend that cannot apply \ + it, so the sync watermark is held back.", + hex::encode(wallet_id) + ); + } + tracing::error!( + wallet_id = %hex::encode(wallet_id), + "Persister lacks CORE_SWEEP_REMOVAL for a changeset carrying sweeps; \ + freezing this wallet's sync watermark rather than trusting an unversioned \ + store() success" + ); + } Ok(()) => { if let Some(h) = offered_height { diag.record_persisted(h); @@ -644,19 +901,15 @@ where // A rejected changeset means these rows are not on disk. Fault // THIS wallet's watermark so it can't outrun them; the next // scan re-emits and the idempotent upserts recover the state. - if let Some(h) = offered_height { - diag.record_rejected(h); - } - fault.fault_wallet(wallet_id, sync_fault); - // Count each faulted wallet once per drain: a wallet that - // entered already faulted was counted at the top of the loop, - // and a repeat rejection must not count it again. - if !is_faulted { - diag.faulted += 1; - } - // One-shot, unambiguous logcat marker via the `log` facade - // (android_logger forwards `log` to logcat; `tracing` may not). - if !freeze_logged.swap(true, Ordering::Relaxed) { + if fault_and_freeze( + diag, + offered_height, + fault, + sync_fault, + wallet_id, + is_faulted, + freeze_logged, + ) { log::error!( "SYNC WATERMARK FROZEN: persister rejected a changeset for wallet {} ({}); \ its durable sync height is now held so the next scan re-persists the \ @@ -673,7 +926,34 @@ where } } } - diag +} + +/// The bookkeeping shared by the two ways a round fails to be durably +/// applied — a rejected `store()`, and a nominal success from a backend +/// that cannot have applied the round's sweeps. Records the withheld +/// advance, faults the wallet (counting it once per drain: a wallet that +/// entered already faulted was counted at the top of the loop, and a +/// repeat failure must not count it again), and returns whether this is +/// the drain's first freeze — the caller owns the one-shot `log`-facade +/// line, whose wording differs per cause (android_logger forwards `log` +/// to logcat; `tracing` may not). +fn fault_and_freeze( + diag: &mut BatchDiagnostics, + offered_height: Option, + fault: &mut AdapterFaultState, + sync_fault: &AtomicBool, + wallet_id: WalletId, + entered_faulted: bool, + freeze_logged: &AtomicBool, +) -> bool { + if let Some(h) = offered_height { + diag.record_rejected(h); + } + fault.fault_wallet(wallet_id, sync_fault); + if !entered_faulted { + diag.faulted += 1; + } + !freeze_logged.swap(true, Ordering::Relaxed) } /// Durable-watermark guard. @@ -765,6 +1045,23 @@ async fn reconstruct_asset_locks_for_event( ) .await; } + // The subtractive arm: a swept funding tx can never confirm, so + // every tracked lock it funds is dead. Nothing else cascades the + // sweep into this table — without this arm the entry is a zombie + // `resume_asset_lock` re-broadcasts and waits on without bound, + // mirrored forever by every store. A chainlocked return re-emits + // the funding record through the arms above, which re-insert the + // entry, so removal here is not a one-way door. + WalletEvent::TransactionsSwept { + wallet_id, txids, .. + } => { + return reconstruction::remove_tracked_asset_locks_for_swept( + wallet_manager, + wallet_id, + txids, + ) + .await; + } _ => return AssetLockChangeSet::default(), }; if candidates.is_empty() { @@ -811,12 +1108,34 @@ async fn build_core_changeset( // emit and drain): emit NO row rather than let a lone // stale slice supersede a complete fold earlier in this // drain's batch — the chainlock's own events carry the - // row's finality forward. + // row's finality forward. The event's input details are + // still the only evidence left of what the transaction + // consumed, though: the wallet may equally have SWEPT it + // (a delayed detection of a loser), and a sweep cannot + // recover the inputs of a row no store ever held. They are + // kept aside under the record's txid for the adapter to + // attach to whichever sweep in the drain removed it — see + // `CoreChangeSet::dropped_record_spends` and + // `CoreChangeSet::dropped_record_input_claims`. let slices: Vec = match wallet_slices_for_txid(wallet_manager, wallet_id, &record.txid).await { Some(slices) => slices, None => vec![(**record).clone()], }; + let mut dropped_record_spends = BTreeMap::new(); + let mut dropped_record_input_claims = BTreeMap::new(); + if slices.is_empty() { + dropped_record_spends.insert(record.txid, derive_spent_utxos(record)); + dropped_record_input_claims.insert( + record.txid, + record + .transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(), + ); + } // A contact's watch-only chain never defines the wallet's // transaction row or its TXOs (see `is_contact_watch_only`); // the usage deltas below are still emitted, so the event @@ -838,6 +1157,8 @@ async fn build_core_changeset( // pre-fix build persisted still clears the stale row. new_utxos: owned.iter().flat_map(derive_new_utxos).collect(), spent_utxos: slices.iter().flat_map(derive_spent_utxos).collect(), + dropped_record_spends, + dropped_record_input_claims, records: folded, account_records: owned, // Mirror the upstream-emitted derived addresses @@ -933,6 +1254,61 @@ async fn build_core_changeset( cs.account_highest_used = account_highest_used; cs } + WalletEvent::TransactionsSwept { + txids, + superseded_by, + winner_mined_height, + released_outpoints, + .. + } => { + // The only subtractive event upstream emits. Each txid was a + // recorded spend that `superseded_by` beat to an input, so it can + // never confirm and the wallet has already dropped it. Mirroring + // the removal is not optional: every other arm here appends, so a + // persister that skipped this would keep the dead rows, hand them + // back on the next load, and re-create the balance the wallet + // just corrected — the exact bug the upstream sweep fixes. + // + // No `spent_utxos` entry for the inputs: a wallet-relevant winner + // claims them through its own record. This arm names the dead and + // the coins their removal freed — the persister holds every input + // of what it deletes, so `released_outpoints` is the only thing + // that tells it which of those to hand back. It cannot work that + // out from the txids: the transaction that took the rest may + // never appear in this wallet's stream at all. + tracing::debug!( + swept = txids.len(), + released = released_outpoints.len(), + superseded_by = %superseded_by, + winner_mined_height = ?winner_mined_height, + "Mirroring swept transactions to the persister" + ); + CoreChangeSet { + sweeps: vec![SweepBatch { + txids: txids.clone(), + superseded_by: *superseded_by, + // The winner's finality context rides with the batch: + // only the event has it (the winner may never appear in + // this wallet's records), and every persister keys the + // lifetime of a held-but-unfunded placeholder on it — + // `Some` anchors the hold at a height that chainlocks, + // `None` (IS-locked, unmined) leaves the hold unstamped + // and uncollectible, the durable stand-in for the + // `spent_outpoints` retention upstream cannot rebuild + // once the loser's record is gone. + winner_mined_height: *winner_mined_height, + released_outpoints: released_outpoints.clone(), + // Filled in by the adapter once the whole drain is + // visible: a detection projected earlier in the same + // drain may have found this batch's loser already + // gone from the live wallet, and its input details + // are then attached here (`settle_dropped_record_ + // spends`). The event itself carries none. + claimed_inputs: vec![], + }], + ..CoreChangeSet::default() + } + } WalletEvent::SyncHeightAdvanced { height, .. } => CoreChangeSet { synced_height: Some(*height), ..CoreChangeSet::default() @@ -1400,8 +1776,11 @@ impl CoreChangeSet { /// circuits on the common case. fn is_empty_no_records(&self) -> bool { self.records.is_empty() + && self.sweeps.is_empty() && self.account_records.is_empty() && self.spent_utxos.is_empty() + && self.dropped_record_spends.is_empty() + && self.dropped_record_input_claims.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() && self.last_processed_height.is_none() @@ -1413,6 +1792,290 @@ impl CoreChangeSet { } } +#[cfg(test)] +mod swept_transaction_projection_tests { + //! Coverage for the one subtractive arm of [`build_core_changeset`]. + //! + //! A sweep carries txids and no records, so it has to survive the + //! `is_empty_no_records` filter on the strength of the txids alone — + //! that filter is what decides whether the persister is called at all, + //! and a sweep that never reaches it leaves the dead rows on disk. + + use super::*; + use dashcore::hashes::Hash; + use dashcore::Txid; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletManager; + + const WALLET_ID: WalletId = [7u8; 32]; + + fn test_manager() -> Arc>> { + Arc::new(RwLock::new(WalletManager::::new( + dashcore::Network::Testnet, + ))) + } + + fn txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + fn outpoint(byte: u8, vout: u32) -> OutPoint { + OutPoint { + txid: txid(byte), + vout, + } + } + + /// A minimal record for `txid` — only its identity matters here, since + /// the merge keys reinstatement on the txid alone. + fn record_for(txid: Txid) -> TransactionRecord { + let tx = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + key_wallet::transaction_checking::transaction_router::TransactionType::Standard, + key_wallet::managed_account::transaction_record::TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + record.txid = txid; + record + } + + /// Mined height every block-context sweep event in these tests carries. + const WINNER_HEIGHT: u32 = 700; + + fn swept(txids: Vec) -> WalletEvent { + swept_releasing(txids, vec![]) + } + + fn swept_releasing(txids: Vec, released_outpoints: Vec) -> WalletEvent { + WalletEvent::TransactionsSwept { + wallet_id: WALLET_ID, + txids, + superseded_by: txid(0xff), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints, + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + } + } + + #[tokio::test] + async fn sweep_names_the_dead_transactions_and_nothing_else() { + let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1), txid(2)])).await; + + assert_eq!( + cs.sweeps, + vec![SweepBatch { + txids: vec![txid(1), txid(2)], + superseded_by: txid(0xff), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + claimed_inputs: vec![], + }] + ); + // A wallet-relevant winner claims the inputs through its own + // record; this arm must not invent UTXO deltas of its own. + assert!(cs.records.is_empty(), "a sweep carries no records"); + assert!(cs.spent_utxos.is_empty(), "a sweep spends nothing"); + assert!(cs.new_utxos.is_empty(), "a sweep creates nothing"); + } + + /// An IS-locked winner's sweep carries `winner_mined_height: None` + /// through to the batch untouched. Every persister keys the lifetime of + /// a held-but-unfunded placeholder on this field — a bridge that + /// fabricated a height here would hand the placeholder a finality + /// horizon the winner does not have, and one that dropped the `Some` + /// leg would make block-context holds uncollectible. + #[tokio::test] + async fn sweep_carries_the_winners_finality_context_verbatim() { + let event = WalletEvent::TransactionsSwept { + wallet_id: WALLET_ID, + txids: vec![txid(1)], + superseded_by: txid(0xff), + winner_mined_height: None, + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }; + let cs = build_core_changeset(&test_manager(), &event).await; + assert_eq!( + cs.sweeps[0].winner_mined_height, None, + "an unmined IS-locked winner must cross the bridge with no mined height" + ); + } + + #[tokio::test] + async fn sweep_reaches_the_persister() { + let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1)])).await; + + assert!( + !cs.is_empty_no_records(), + "a sweep-only round must not be filtered out as empty — that \ + filter decides whether the persister is called at all" + ); + assert!(!Merge::is_empty(&cs)); + } + + /// The released set is what a persister acts on, so it has to survive + /// the projection intact — it cannot be recovered from the txids, since + /// the transaction that took the remaining inputs may never appear here. + #[tokio::test] + async fn sweep_carries_the_outpoints_it_released() { + let cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![txid(1)], vec![outpoint(9, 1)]), + ) + .await; + + assert_eq!(cs.sweeps[0].released_outpoints, vec![outpoint(9, 1)]); + } + + /// An ordinary resend frees nothing: the winner took every input the + /// removed transaction named. + #[tokio::test] + async fn a_sweep_that_freed_nothing_releases_nothing() { + let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1)])).await; + + assert!(cs.sweeps[0].released_outpoints.is_empty()); + } + + /// Merging keeps every sweep as its own batch, in arrival order. + /// + /// Folding them would lose the only thing that makes a later sweep able + /// to correct an earlier one — see the ordering test below, which is the + /// case that actually breaks. + #[tokio::test] + async fn merged_sweeps_stay_separate_and_ordered() { + let mut cs = build_core_changeset(&test_manager(), &swept(vec![txid(1), txid(2)])).await; + let second = build_core_changeset(&test_manager(), &swept(vec![txid(3)])).await; + + cs.merge(second); + + assert_eq!(cs.sweeps.len(), 2); + assert_eq!(cs.sweeps[0].txids, vec![txid(1), txid(2)]); + assert_eq!(cs.sweeps[1].txids, vec![txid(3)]); + } + + /// A record arriving after a sweep of the same transaction reinstates + /// it. Every persister writes records before replaying sweeps, so a + /// buffered sweep would otherwise delete a row the wallet has since + /// brought back. + /// + /// Reachable through IS-lock precedence: an unconfirmed transaction is + /// swept when an IS-locked conflict arrives, then returns chainlocked + /// and sweeps that conflict in turn — leaving one round holding both + /// removals plus the reinstating record. + #[tokio::test] + async fn a_record_arriving_after_its_sweep_survives_the_round() { + let reinstated = txid(1); + + let mut cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![reinstated], vec![outpoint(9, 1)]), + ) + .await; + assert_eq!( + cs.sweeps.len(), + 1, + "sanity: the sweep is there to begin with" + ); + + // The wallet records it again, which is the newer fact. + let mut later = CoreChangeSet::default(); + later.records.push(record_for(reinstated)); + cs.merge(later); + + assert!( + cs.sweeps.is_empty(), + "the sweep must not delete a transaction the wallet brought back" + ); + assert_eq!(cs.records.len(), 1); + } + + /// Only the reinstated transaction leaves the batch; anything else it + /// removed still goes — and so does everything that batch freed. + /// + /// `released_outpoints` is the aggregate for every loser in the batch, so + /// dropping it would discard coins freed by the losers still going. The + /// entries belonging to the reinstated transaction do no harm: every + /// backend either scopes its release to the remaining losers' own inputs + /// or withholds an outpoint a surviving record claims, and the + /// reinstating record is exactly such a claim. + #[tokio::test] + async fn a_reinstated_record_only_rescues_its_own_transaction() { + let reinstated = txid(1); + let still_dead = txid(2); + let freed_by_the_survivor = outpoint(9, 2); + + let mut cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![reinstated, still_dead], vec![freed_by_the_survivor]), + ) + .await; + let mut later = CoreChangeSet::default(); + later.records.push(record_for(reinstated)); + cs.merge(later); + + assert_eq!(cs.sweeps.len(), 1); + assert_eq!(cs.sweeps[0].txids, vec![still_dead]); + assert_eq!( + cs.sweeps[0].released_outpoints, + vec![freed_by_the_survivor], + "a coin the still-swept loser freed must survive the reinstatement" + ); + } + + /// A release is only true of the wallet the sweep that made it saw. A + /// later sweep can remove the transaction that re-spent the freed coin + /// while keeping the coin spent, because its own winner took it — and + /// that answer has to win, since it is the later one. + /// + /// Unioning the release sets loses exactly this: the earlier "B is free" + /// outlives the later "B is spent", and every backend then persists a + /// coin the chain consumed as spendable. + #[tokio::test] + async fn a_later_sweep_that_keeps_a_coin_spent_outlives_an_earlier_release() { + let freed = outpoint(9, 1); + + let mut cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![txid(1)], vec![freed]), + ) + .await; + // The second sweep removes the transaction that took `freed` and + // releases nothing: its own winner consumed that coin. + let second = + build_core_changeset(&test_manager(), &swept_releasing(vec![txid(2)], vec![])).await; + + cs.merge(second); + + assert_eq!( + cs.sweeps.len(), + 2, + "the two answers must stay distinguishable" + ); + assert_eq!(cs.sweeps[0].released_outpoints, vec![freed]); + assert!( + cs.sweeps[1].released_outpoints.is_empty(), + "the later sweep kept the coin spent, and applying it after the \ + first is what makes that stick" + ); + } +} + #[cfg(test)] mod contact_watch_only_projection_tests { //! Regression coverage for the persist-time projection of records @@ -2830,7 +3493,7 @@ mod tests { // lossless burst, a rejected `store()`, the per-wallet freeze, and // per-wallet batch folding. - use super::{run_wallet_event_adapter, AdapterFaultState}; + use super::{run_wallet_event_adapter, AdapterFaultState, ADAPTER_STORE_BATCH_LIMIT}; use crate::changeset::changeset::PlatformWalletChangeSet; use crate::changeset::client_start_state::ClientStartState; use crate::changeset::traits::{PersistenceError, PlatformWalletPersistence}; @@ -2840,7 +3503,7 @@ mod tests { use std::collections::{BTreeMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; - use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; + use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; @@ -2853,6 +3516,7 @@ mod tests { last_processed_height: Option, n_records: usize, n_asset_locks: usize, + n_asset_locks_removed: usize, rejected: bool, } @@ -2863,6 +3527,7 @@ mod tests { struct ProbePersister { obs: UnboundedSender, fail_once: Mutex>, + capabilities: crate::changeset::PersistenceCapabilities, /// Wallets whose NEXT `store()` panics instead of returning. Models a /// backend that dies mid-write — which surfaces as a `JoinError` /// instead of unwinding the whole adapter task. @@ -2876,16 +3541,67 @@ mod tests { blocked: Arc, } + /// Captures the adapter's sweep-bearing projection so the regression can + /// pin both sides of the empty-live-snapshot contract: no stale record is + /// restored, but the event's spent-input evidence is retained. + struct SweepProjectionPersister { + stored: UnboundedSender, + } + + impl SweepProjectionPersister { + fn new(stored: UnboundedSender) -> Self { + Self { stored } + } + } + + impl PlatformWalletPersistence for SweepProjectionPersister { + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL + } + + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if let Some(core) = changeset.core { + let _ = self.stored.send(core); + } + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + impl ProbePersister { fn new(obs: UnboundedSender) -> Self { Self { obs, fail_once: Mutex::new(HashSet::new()), + capabilities: crate::changeset::PersistenceCapabilities::NONE, panic_once: Mutex::new(HashSet::new()), block_until: Mutex::new(None), blocked: Arc::new(AtomicBool::new(false)), } } + /// A probe that additionally attests `capabilities` — used by the + /// `CORE_SWEEP_REMOVAL` gate tests, which need a persister on record + /// as (not) supporting the sweep contract. + fn with_capabilities( + obs: UnboundedSender, + capabilities: crate::changeset::PersistenceCapabilities, + ) -> Self { + Self { + capabilities, + ..Self::new(obs) + } + } /// Park the next `store()` until the returned sender is dropped or /// signalled. `blocked` reports when the park is actually in effect. fn block_next(&self) -> (std::sync::mpsc::Sender<()>, Arc) { @@ -2902,6 +3618,10 @@ mod tests { } impl PlatformWalletPersistence for ProbePersister { + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + self.capabilities + } + fn store( &self, wallet_id: WalletId, @@ -2930,6 +3650,11 @@ mod tests { .as_ref() .map(|a| a.asset_locks.len()) .unwrap_or(0), + n_asset_locks_removed: changeset + .asset_locks + .as_ref() + .map(|a| a.removed.len()) + .unwrap_or(0), rejected, }); if rejected { @@ -3069,7 +3794,10 @@ mod tests { // 3) Sentinel proving the loop moved past the watermark. tx.send(block_processed_event(wallet_id, 20)).unwrap(); - let sentinel = obs_rx.recv().await.expect("sentinel store must arrive"); + let sentinel = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) + .await + .expect("the sentinel store must arrive rather than hanging the suite") + .expect("sentinel store must arrive"); assert_eq!( sentinel.last_processed_height, Some(20), @@ -3612,13 +4340,599 @@ mod tests { } } - /// End-to-end restore-scan shape through the real adapter loop: a - /// `BlockProcessed` event whose inserted record is an asset-lock tx - /// filed under a funding account must (a) repopulate the wallet's - /// in-memory `tracked_asset_locks` and (b) carry the reconstructed - /// row to the persister in the same `store()` as the core record. - /// This is the path that rebuilds the host's persisted asset-lock - /// mirror after a wipe & recover. + /// Mined height every block-context sweep event in this module carries. + const WINNER_HEIGHT: u32 = 700; + + /// A `TransactionsSwept` event for a helper below. + fn swept_event(wallet_id: WalletId, txid_byte: u8, superseded_by_byte: u8) -> WalletEvent { + use dashcore::hashes::Hash as _; + WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![dashcore::Txid::from_byte_array([txid_byte; 32])], + superseded_by: dashcore::Txid::from_byte_array([superseded_by_byte; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + } + } + + /// A wallet the manager knows but that no longer holds `loser` — the + /// live state by the time a delayed detection of `loser` is drained — + /// plus the detection event itself: an outgoing spend of the wallet's + /// funding coin, paying only outside addresses. + fn swept_before_persisted_fixture() -> ( + Arc>>, + WalletId, + dashcore::Transaction, + dashcore::OutPoint, + WalletEvent, + ) { + swept_before_persisted_fixture_with_known_funding(true) + } + + fn swept_before_persisted_fixture_with_known_funding( + funding_known: bool, + ) -> ( + Arc>>, + WalletId, + dashcore::Transaction, + dashcore::OutPoint, + WalletEvent, + ) { + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + use dashcore::hashes::Hash as _; + use dashcore::{ + Address, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness, + }; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + InputDetail, OutputDetail, OutputRole, TransactionDirection, TransactionRecord, + }; + use key_wallet::test_utils::TestWalletContext; + use key_wallet::transaction_checking::{TransactionContext, TransactionType}; + + let ctx = TestWalletContext::new_random(); + let funding_address = ctx.receive_address.clone(); + let funding_outpoint = OutPoint::new(Txid::from_byte_array([0x61; 32]), 0); + let external_address = Address::dummy(Network::Testnet, 7); + let loser = Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: funding_outpoint, + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: Witness::new(), + }], + output: vec![TxOut { + value: 99_999_000, + script_pubkey: external_address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let record = TransactionRecord::new( + loser.clone(), + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Outgoing, + funding_known + .then_some(InputDetail { + index: 0, + value: 100_000_000, + address: funding_address, + }) + .into_iter() + .collect(), + vec![OutputDetail { + index: 0, + role: OutputRole::Sent, + address: Some(external_address), + value: 99_999_000, + }], + -100_000_000, + ); + + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + generation: Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + observed_input_conflicts: Default::default(), + }; + let mut manager = WalletManager::::new(Network::Testnet); + let wallet_id = manager + .insert_wallet(ctx.wallet, info) + .expect("insert wallet"); + let detected = WalletEvent::TransactionDetected { + wallet_id, + record: Box::new(record), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }; + ( + Arc::new(RwLock::new(manager)), + wallet_id, + loser, + funding_outpoint, + detected, + ) + } + + fn sweep_of( + wallet_id: WalletId, + loser: &dashcore::Transaction, + winner_byte: u8, + ) -> WalletEvent { + use dashcore::hashes::Hash as _; + WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![loser.txid()], + superseded_by: dashcore::Txid::from_byte_array([winner_byte; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + } + } + + async fn next_store(rx: &mut UnboundedReceiver) -> CoreChangeSet { + tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()) + .await + .expect("the drain must persist") + .expect("the persister stays connected") + } + + /// A transaction may disappear from the live manager before its queued + /// detection event reaches persistence. No store then holds a row whose + /// inputs the sweep could walk, so the detection event's input details + /// are the only evidence that the winning external payment consumed the + /// wallet's funding coin — and they must travel WITH the sweep, as its + /// claimed inputs, so every persister settles them under the winner's + /// attribution rather than as an unattributed mark the funding output's + /// re-delivery would erase. + #[tokio::test] + async fn delayed_detection_after_sweep_attaches_its_inputs_to_the_sweep_without_restoring_stale_record( + ) { + let (manager, wallet_id, loser, funding_outpoint, detected) = + swept_before_persisted_fixture(); + let (stored_tx, mut stored_rx) = unbounded_channel(); + let persister = Arc::new(SweepProjectionPersister::new(stored_tx)); + let (event_tx, event_rx) = unbounded_channel(); + event_tx.send(detected).unwrap(); + event_tx.send(sweep_of(wallet_id, &loser, 0x62)).unwrap(); + + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + manager, + Arc::clone(&persister), + event_rx, + Arc::new(AtomicBool::new(false)), + cancel.clone(), + )); + let persisted = next_store(&mut stored_rx).await; + cancel.cancel(); + drop(event_tx); + handle.await.unwrap(); + + assert!( + persisted.records.is_empty(), + "the delayed stale record must not be restored after the live sweep" + ); + assert!( + persisted.account_records.is_empty(), + "the delayed stale account record must not be restored after the live sweep" + ); + assert_eq!(persisted.sweeps.len(), 1); + assert_eq!( + persisted.sweeps[0].claimed_inputs, + vec![funding_outpoint], + "the external winner's consumed funding coin must reach persistence as the \ + sweep's claimed input" + ); + assert!( + persisted.spent_utxos.is_empty(), + "attributed evidence is not doubled as an unattributed spend" + ); + } + + /// A delayed detection still carries every raw transaction input when + /// the wallet could not classify its funding output. The sweep must keep + /// that outpoint claimed even though there is no `InputDetail` from which + /// to synthesize an ordinary spent UTXO. + #[tokio::test] + async fn delayed_detection_after_sweep_claims_raw_input_when_funding_output_was_unknown() { + let (manager, wallet_id, loser, funding_outpoint, detected) = + swept_before_persisted_fixture_with_known_funding(false); + let (stored_tx, mut stored_rx) = unbounded_channel(); + let persister = Arc::new(SweepProjectionPersister::new(stored_tx)); + let (event_tx, event_rx) = unbounded_channel(); + event_tx.send(detected).unwrap(); + event_tx.send(sweep_of(wallet_id, &loser, 0x62)).unwrap(); + + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + manager, + Arc::clone(&persister), + event_rx, + Arc::new(AtomicBool::new(false)), + cancel.clone(), + )); + let persisted = next_store(&mut stored_rx).await; + cancel.cancel(); + drop(event_tx); + handle.await.unwrap(); + + assert!(persisted.records.is_empty()); + assert!(persisted.account_records.is_empty()); + assert_eq!(persisted.sweeps.len(), 1); + assert_eq!( + persisted.sweeps[0].claimed_inputs, + vec![funding_outpoint], + "the external winner's hold must not depend on funding-output classification" + ); + assert!(persisted.spent_utxos.is_empty()); + } + + /// The sweep event can sit just past the drain's fold limit. The + /// detection's drain then writes the coin spent (unattributed — better + /// than handing it out meanwhile) and keeps the evidence for the next + /// drain, where the sweep picks it up as its claimed input. + #[tokio::test] + async fn delayed_detection_evidence_carries_into_the_next_drains_sweep() { + let (manager, wallet_id, loser, funding_outpoint, detected) = + swept_before_persisted_fixture(); + let (stored_tx, mut stored_rx) = unbounded_channel(); + let persister = Arc::new(SweepProjectionPersister::new(stored_tx)); + let (event_tx, event_rx) = unbounded_channel(); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + manager, + Arc::clone(&persister), + event_rx, + Arc::new(AtomicBool::new(false)), + cancel.clone(), + )); + + event_tx.send(detected).unwrap(); + let first = next_store(&mut stored_rx).await; + assert!(first.sweeps.is_empty()); + assert!(first.records.is_empty()); + assert_eq!( + first + .spent_utxos + .iter() + .map(|u| u.outpoint) + .collect::>(), + vec![funding_outpoint], + "with no sweep in its own drain the spend is written unattributed right away" + ); + + event_tx.send(sweep_of(wallet_id, &loser, 0x62)).unwrap(); + let second = next_store(&mut stored_rx).await; + assert_eq!(second.sweeps.len(), 1); + assert_eq!( + second.sweeps[0].claimed_inputs, + vec![funding_outpoint], + "the next drain's sweep of that txid claims the carried evidence" + ); + + cancel.cancel(); + drop(event_tx); + handle.await.unwrap(); + } + + /// Once a drain has run the channel dry, evidence it did not pair has + /// nothing left to wait for beyond the one-drain grace: a record can + /// also vanish because it was chain-locked and pruned, and nothing will + /// ever sweep that one, so the carry must not accumulate. + #[tokio::test] + async fn delayed_detection_evidence_is_dropped_after_one_unpaired_drain() { + let (manager, wallet_id, loser, funding_outpoint, detected) = + swept_before_persisted_fixture(); + let (stored_tx, mut stored_rx) = unbounded_channel(); + let persister = Arc::new(SweepProjectionPersister::new(stored_tx)); + let (event_tx, event_rx) = unbounded_channel(); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + manager, + Arc::clone(&persister), + event_rx, + Arc::new(AtomicBool::new(false)), + cancel.clone(), + )); + + event_tx.send(detected).unwrap(); + let first = next_store(&mut stored_rx).await; + assert_eq!(first.spent_utxos.len(), 1); + + // An unrelated drain of the same wallet: the carried evidence is + // offered to it, pairs with nothing, and is dropped. + event_tx + .send(WalletEvent::SyncHeightAdvanced { + wallet_id, + height: 5, + }) + .unwrap(); + let second = next_store(&mut stored_rx).await; + assert!(second.spent_utxos.is_empty(), "nothing is re-written"); + + event_tx.send(sweep_of(wallet_id, &loser, 0x62)).unwrap(); + let third = next_store(&mut stored_rx).await; + assert_eq!(third.sweeps.len(), 1); + assert!( + third.sweeps[0].claimed_inputs.is_empty(), + "evidence older than one drain no longer attaches; {:?} was expected gone", + funding_outpoint + ); + + cancel.cancel(); + drop(event_tx); + handle.await.unwrap(); + } + + /// Under a backlog the sweep can sit several fold-limit drains behind + /// the detection. The evidence must survive every drain that stopped at + /// the limit, because the sweep was already queued when the detection + /// was projected and the channel has not run dry since. + #[tokio::test] + async fn delayed_detection_evidence_survives_a_backlog_of_full_drains() { + let (manager, wallet_id, loser, funding_outpoint, detected) = + swept_before_persisted_fixture(); + let (stored_tx, mut stored_rx) = unbounded_channel(); + let persister = Arc::new(SweepProjectionPersister::new(stored_tx)); + let (event_tx, event_rx) = unbounded_channel(); + + // Queue everything up front: the detection, more than two full + // drains of filler, then the sweep. + event_tx.send(detected).unwrap(); + let filler = ADAPTER_STORE_BATCH_LIMIT * 2 + ADAPTER_STORE_BATCH_LIMIT / 2; + for height in 0..filler as u32 { + event_tx + .send(WalletEvent::SyncHeightAdvanced { wallet_id, height }) + .unwrap(); + } + event_tx.send(sweep_of(wallet_id, &loser, 0x62)).unwrap(); + + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + manager, + Arc::clone(&persister), + event_rx, + Arc::new(AtomicBool::new(false)), + cancel.clone(), + )); + + let first = next_store(&mut stored_rx).await; + assert!(first.sweeps.is_empty()); + assert_eq!(first.spent_utxos.len(), 1, "written unattributed at once"); + let mut paired = None; + for _ in 0..4 { + let store = next_store(&mut stored_rx).await; + if !store.sweeps.is_empty() { + paired = Some(store); + break; + } + assert!( + store.spent_utxos.is_empty(), + "the interim mark is written once" + ); + } + let paired = paired.expect("the sweep must reach the persister"); + assert_eq!( + paired.sweeps[0].claimed_inputs, + vec![funding_outpoint], + "the sweep still claims the evidence after several full drains" + ); + + cancel.cancel(); + drop(event_tx); + handle.await.unwrap(); + } + + /// dashpay/platform#4406 (finding 2): sweeps reach an FFI host only + /// through the persistence extension's size-negotiated sweep slot, so a + /// persister predating it processes the rest of the round and returns + /// success without ever seeing `core.sweeps`. A `store()` that comes + /// back `Ok` therefore proves nothing about whether a swept loser's + /// row was actually removed unless the persister has separately + /// attested `CORE_SWEEP_REMOVAL`. A persister that never declares it + /// (the probe's default) must be treated exactly like a rejection when + /// a round carries a sweep — even though, unlike the rejection tests + /// above, the probe's own `store()` call reports success. + #[tokio::test] + async fn sweep_without_declared_capability_freezes_the_wallet_despite_a_successful_store() { + let wallet_id = [21u8; 32]; + let (tx, rx) = unbounded_channel::(); + let (obs_tx, mut obs_rx) = unbounded_channel(); + // No capabilities declared — the pre-`CORE_SWEEP_REMOVAL` shape. + 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(), + Arc::clone(&persister), + rx, + Arc::clone(&sync_fault), + cancel.clone(), + )); + + tx.send(swept_event(wallet_id, 0x51, 0x52)).unwrap(); + let first = obs_rx + .recv() + .await + .expect("the round is still handed to store()"); + assert!( + !first.rejected, + "the probe's own store() must succeed — the gate lives in the \ + adapter, not in a persister that has no idea sweeps exist" + ); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !sync_fault.load(Ordering::Relaxed) { + tokio::task::yield_now().await; + } + }) + .await + .expect( + "the fail-closed guard must trip for an undeclared sweep even \ + though store() itself reported success", + ); + + // A later watermark-only event must be stripped just like it would + // be after a real store() rejection. + tx.send(sync_height_event(wallet_id, 500)).unwrap(); + tx.send(block_processed_event(wallet_id, 40)).unwrap(); + let sentinel = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) + .await + .expect("the sentinel store must arrive rather than hanging the suite") + .expect("sentinel store must arrive"); + assert_eq!(sentinel.last_processed_height, Some(40)); + assert_eq!( + sentinel.synced_height, None, + "the watermark must stay frozen: a removal must never be \ + reported durable to a backend that never attested it can apply it" + ); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + /// The coalesced shape of the same gap, which is the one that actually + /// loses data. The adapter folds whatever is buffered, so a sweep and a + /// following watermark advance arrive in ONE changeset — and + /// `synced_height` sits in the unchanged prefix a pre-sweep persister + /// does read and commit. + /// + /// Faulting after `store()` returns cannot retract a watermark the + /// backend has already made durable: on the next launch the wallet + /// believes those blocks are scanned, never re-matches them, and the + /// removal that round carried is lost for good. So the height has to be + /// stripped before the changeset is handed over, not after. + #[tokio::test] + async fn a_coalesced_sweep_and_watermark_never_commits_the_height() { + let wallet_id = [23u8; 32]; + let (tx, rx) = unbounded_channel::(); + // Buffered before the adapter starts, so both events are guaranteed + // to land in the same drain rather than racing it. + tx.send(swept_event(wallet_id, 0x61, 0x62)).unwrap(); + tx.send(sync_height_event(wallet_id, 900)).unwrap(); + + let (obs_tx, mut obs_rx) = unbounded_channel(); + // No capabilities declared — the pre-`CORE_SWEEP_REMOVAL` shape. + 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(), + Arc::clone(&persister), + rx, + Arc::clone(&sync_fault), + cancel.clone(), + )); + + // Bounded like the neighbouring capability tests below: both the + // adapter and `ProbePersister` hold their own sender, so a + // regression that stops the folded round from reaching `store()` + // would otherwise hang this test instead of failing its assertion. + let observed = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) + .await + .expect("the folded round reaches store() within the timeout") + .expect("the folded round reaches store()"); + assert_eq!( + observed.synced_height, None, + "an unattested persister must never be handed the watermark of a \ + round whose removal it cannot apply" + ); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !sync_fault.load(Ordering::Relaxed) { + tokio::task::yield_now().await; + } + }) + .await + .expect("the fail-closed guard must still trip for the folded round"); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + /// The positive case for the same gate: a persister that attests + /// `CORE_SWEEP_REMOVAL` is trusted normally, and the watermark keeps + /// advancing through a sweep-bearing round exactly as it would through + /// any other. + #[tokio::test] + async fn sweep_with_declared_capability_does_not_freeze() { + let wallet_id = [22u8; 32]; + let (tx, rx) = unbounded_channel::(); + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL, + )); + let sync_fault = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + test_manager(), + Arc::clone(&persister), + rx, + Arc::clone(&sync_fault), + 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(); + + let mut last_synced = None; + // Drain until a store carries the watermark. Each receive is bounded: + // the adapter and the probe both hold the sender alive, so a plain + // `recv()` would never report the channel quiet — a regression that + // stops the watermark would hang here until the suite's own timeout + // instead of failing on the assertion below. + for _ in 0..10 { + match tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()).await { + Ok(Some(observed)) => { + assert!(!observed.rejected); + if let Some(h) = observed.synced_height { + last_synced = Some(h); + break; + } + } + Ok(None) | Err(_) => break, + } + } + assert_eq!( + last_synced, + Some(700), + "the watermark must advance normally once the backend attests \ + CORE_SWEEP_REMOVAL" + ); + assert!( + !sync_fault.load(Ordering::Relaxed), + "an attested backend must never trip the fail-closed guard" + ); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + /// End-to-end restore-scan shape through the real adapter loop: a + /// `BlockProcessed` event whose inserted record is an asset-lock tx + /// filed under a funding account must (a) repopulate the wallet's + /// in-memory `tracked_asset_locks` and (b) carry the reconstructed + /// row to the persister in the same `store()` as the core record. + /// This is the path that rebuilds the host's persisted asset-lock + /// mirror after a wipe & recover. #[tokio::test] async fn block_processed_asset_lock_record_reconstructs_and_persists() { use dashcore::hashes::Hash as _; @@ -3741,6 +5055,277 @@ mod tests { handle.await.expect("adapter task joins"); } + /// The `TransactionsSwept` arm end to end: a sweep naming a tracked + /// lock's funding tx must drop the in-memory entry and carry the + /// tombstone to the persister through the same `removed` channel a + /// rejected-at-broadcast `Built` row uses. A swept funding tx can + /// never confirm, so without this the entry is a zombie + /// `resume_asset_lock` re-broadcasts and waits on without bound, and + /// every store mirrors it forever. + #[tokio::test] + async fn transactions_swept_removes_the_tracked_asset_lock_it_funded() { + use dashcore::hashes::Hash as _; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use tokio::sync::Notify; + + use super::spawn_wallet_event_adapter; + use crate::test_support::{ + funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(dashcore::Network::Testnet) + .build() + .expect("mock sdk"), + ); + let asset_lock_manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let (tx, _path) = asset_lock_manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("build asset lock"); + + let record = TransactionRecord::new( + tx.clone(), + AccountType::IdentityRegistration, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 4321, + dashcore::BlockHash::all_zeros(), + 1_650_000_000, + )), + TransactionType::AssetLock, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ); + + let (obs_tx, mut obs_rx) = unbounded_channel(); + // Attested for sweeps AND payments: the removal must ride an + // ordinary round, and the flip's overlay is only staged for a + // payment-durable backend. + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS) + .union(crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS), + )); + let (event_tx, event_rx) = unbounded_channel(); + let cancel = CancellationToken::new(); + let sync_fault = Arc::new(AtomicBool::new(false)); + let handle = spawn_wallet_event_adapter( + Arc::clone(&wallet_manager), + Arc::clone(&persister), + event_rx, + Arc::clone(&sync_fault), + cancel.clone(), + ); + + // Track the lock the same way a restore scan would. + event_tx + .send(WalletEvent::BlockProcessed { + wallet_id, + height: 4321, + chain_lock: None, + inserted: vec![record], + updated: vec![], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }) + .expect("send reconstruction event"); + let observed = obs_rx.recv().await.expect("reconstruction store"); + assert_eq!(observed.n_asset_locks, 1, "sanity: the entry is tracked"); + + // The funding tx is swept. + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![tx.txid()], + superseded_by: dashcore::Txid::from_byte_array([0x77; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep event"); + + let observed = obs_rx.recv().await.expect("sweep store"); + assert_eq!( + observed.n_asset_locks_removed, 1, + "the dead lock's tombstone must ride the sweep's own store()" + ); + + let out_point = dashcore::OutPoint::new(tx.txid(), 0); + { + let wm = wallet_manager.read().await; + assert!( + !wm.get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .contains_key(&out_point), + "the in-memory entry must not outlive its swept funding tx" + ); + } + + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + + /// The coalesced sweep-then-chainlocked-reinstatement fold, driven + /// through the REAL producers rather than hand-built changesets: the + /// sweep arm removes the tracked entry and emits its tombstone, the + /// reinstating chainlocked record re-inserts through reconstruction at + /// a non-Consumed status, and folding the two — exactly what the + /// adapter's batched drain does — must cancel the tombstone. Before + /// `AssetLockChangeSet::merge` learned that, the merged changeset + /// carried both, and SQLite (upserts before removals) deleted the row + /// it had just reinstated while the in-memory wallet kept it: the + /// durable tracked lock vanished across a restart even though its + /// funding transaction survived. + #[tokio::test] + async fn a_reinstating_reconstruction_folded_after_a_sweep_cancels_its_tombstone() { + use dashcore::hashes::Hash as _; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use tokio::sync::Notify; + + use crate::changeset::merge::Merge as _; + use crate::test_support::{ + funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::asset_lock::sync::reconstruction; + use crate::wallet::asset_lock::tracked::AssetLockStatus; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(dashcore::Network::Testnet) + .build() + .expect("mock sdk"), + ); + let asset_lock_manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let (tx, _path) = asset_lock_manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("build asset lock"); + let record = TransactionRecord::new( + tx.clone(), + AccountType::IdentityRegistration, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 4321, + dashcore::BlockHash::all_zeros(), + 1_650_000_000, + )), + TransactionType::AssetLock, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ); + let out_point = dashcore::OutPoint::new(tx.txid(), 0); + + // Track the lock the way a restore scan would. + let tracked = reconstruction::reconstruct_tracked_asset_locks( + &wallet_manager, + &wallet_id, + &[&record], + ) + .await; + assert_eq!(tracked.asset_locks.len(), 1, "sanity: the entry is tracked"); + + // The sweep's own changeset, then the reinstating record's — the + // two events a single folded drain can carry back to back. + let mut folded = reconstruction::remove_tracked_asset_locks_for_swept( + &wallet_manager, + &wallet_id, + &[tx.txid()], + ) + .await; + assert!( + folded.removed.contains(&out_point), + "sanity: the sweep produced the tombstone" + ); + let reinstated = reconstruction::reconstruct_tracked_asset_locks( + &wallet_manager, + &wallet_id, + &[&record], + ) + .await; + let reinstated_entry = reinstated + .asset_locks + .get(&out_point) + .expect("reconstruction must re-insert the entry the sweep removed"); + assert_ne!( + reinstated_entry.status, + AssetLockStatus::Consumed, + "sanity: the load-bearing premise — a reinstating reconstruction is non-Consumed" + ); + folded.merge(reinstated); + + assert!( + folded.removed.is_empty(), + "the reinstating upsert must cancel the folded sweep tombstone" + ); + assert!( + folded.asset_locks.contains_key(&out_point), + "and the reinstated entry rides the store round" + ); + } + /// The `ChainLockProcessed` arm end to end: a lock the scan /// reconstructed at a pre-finality status (its block wasn't /// chain-locked yet — the restore-scan norm) upgrades to diff --git a/packages/rs-platform-wallet/src/changeset/merge.rs b/packages/rs-platform-wallet/src/changeset/merge.rs index 9c0d97fad27..79892407fa1 100644 --- a/packages/rs-platform-wallet/src/changeset/merge.rs +++ b/packages/rs-platform-wallet/src/changeset/merge.rs @@ -1,14 +1,22 @@ //! The `Merge` trait for composing changeset deltas. //! -//! Changesets are commutative and associative so that multiple deltas can be -//! batched and reordered without affecting the final result. +//! Changeset merging is an ORDERED, associative operation: a stream of +//! deltas may be folded together in any grouping, but only in the order +//! the deltas were produced. It is NOT commutative — `CoreChangeSet` is +//! the load-bearing example: a record in the later operand retracts an +//! earlier sweep of the same txid (reinstatement), while the reverse +//! order keeps the sweep, and sweep batches append in emission order so +//! a later batch's spend decision replays over an earlier one's release. +//! Reordering or parallelizing a fold can therefore persist a different +//! spend decision, not just a differently-arranged changeset. use std::collections::{BTreeMap, BTreeSet}; -/// Combine two changesets. Changesets are commutative and associative -/// for safe batching and reordering. +/// Combine two changesets: `self` is the earlier delta, `other` the later +/// one. Associative (safe to regroup a fold) but NOT commutative — see +/// the module doc; callers must keep operands in production order. pub trait Merge: Default { - /// Merge another changeset into `self`. + /// Merge `other`, the LATER delta, into `self`. fn merge(&mut self, other: Self); /// Returns `true` if this changeset contains no changes. diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index cd260cc5fae..86d059786a7 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -51,13 +51,46 @@ impl PersistenceCapabilities { /// Tracked asset-lock rows, including status and proof updates, can be /// persisted. Restart hydration is the separate `WALLET_RESTORE` contract. pub const TRACKED_ASSET_LOCKS: Self = Self(1 << 9); - /// Tracked (wallet-independent) masternodes are persisted AND restored /// across restarts /// ([`persist_tracked_masternodes`](super::PlatformWalletPersistence::persist_tracked_masternodes) /// / [`load_tracked_masternodes`](super::PlatformWalletPersistence::load_tracked_masternodes)). /// Without this bit, tracking is session-scoped. pub const TRACKED_MASTERNODES: Self = Self(1 << 10); + /// A stored `CoreChangeSet` whose `sweeps` are non-empty is durably + /// applied batch by batch and in order: each swept transaction and its + /// outputs are excluded from every restore and enumeration path (whether + /// by physical deletion or a durable marker), each released outpoint is + /// freed unless a later surviving claim supersedes that release, and each + /// non-released input retains a durable spend claim even when its funding + /// TXO has not materialized yet. Physical row deletion is an + /// implementation detail, not the contract — the in-tree stores keep an + /// inert globally-swept row until every wallet's scoped cleanup lands, + /// and a detached tombstone MUST outlive its loser or the consumed coin + /// later reads unspent. On the FFI surface sweeps travel through the + /// persistence extension's size-negotiated sweep callback — a slot Rust + /// never reads unless the host's declared `struct_size` proved it exists + /// — so an older host processes the rest of the round, returns success, + /// and never sees the sweeps at all; this bit tells the wallet that the + /// complete sweep contract was implemented rather than silently + /// truncated. + pub const CORE_SWEEP_REMOVAL: Self = Self(1 << 11); + /// A stored changeset's `dashpay_payments_overlay` rows are durably + /// applied. This is what lets the wallet-event adapter couple a sweep's + /// payment consequence (`Pending → Failed` for the losers' sent + /// entries) to the sweep's own atomic store round: the flip is staged + /// onto the round ONLY for a backend attesting this bit, because a + /// sweep never re-emits once its round is durable — an + /// accepted-and-ignored overlay would leave the adapter believing a + /// flip persisted that a host without a payments store silently + /// dropped. A non-attesting backend keeps the in-memory flip (the + /// truthful session state; the transaction IS dead) with nothing + /// round-coupled — funds-safe, since payment entries are display + /// metadata; the funds-critical half of the sweep still gates on + /// `CORE_SWEEP_REMOVAL`. On the FFI surface Rust honours the + /// declaration only when `on_persist_dashpay_payments_fn` is actually + /// wired. + pub const DASHPAY_PAYMENTS: Self = Self(1 << 12); /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -142,6 +175,14 @@ impl PersistenceCapabilities { PersistenceCapabilities::TRACKED_MASTERNODES, "tracked_masternodes", ), + ( + PersistenceCapabilities::CORE_SWEEP_REMOVAL, + "core_sweep_removal", + ), + ( + PersistenceCapabilities::DASHPAY_PAYMENTS, + "dashpay_payments", + ), ]; KNOWN @@ -172,6 +213,8 @@ mod tests { assert_eq!(PersistenceCapabilities::DPNS_NAME_STATES.bits(), 0x100); assert_eq!(PersistenceCapabilities::TRACKED_ASSET_LOCKS.bits(), 0x200); assert_eq!(PersistenceCapabilities::TRACKED_MASTERNODES.bits(), 0x400); + assert_eq!(PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits(), 0x800); + assert_eq!(PersistenceCapabilities::DASHPAY_PAYMENTS.bits(), 0x1000); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 @@ -188,4 +231,21 @@ mod tests { vec!["asset_lock_funding_indices", "wallet_restore"] ); } + + /// Every declarable bit must be nameable. A bit missing from `KNOWN` + /// still gates behaviour but vanishes from every diagnostic that + /// reports capabilities by name, so a host debugging why its rows + /// never landed sees nothing about the capability that withheld them + /// — which is exactly what `DASHPAY_PAYMENTS` did until this test. + #[test] + fn every_declared_bit_has_a_stable_name() { + for shift in 0..13u32 { + let bit = PersistenceCapabilities::from_bits_retain(1 << shift); + assert_eq!( + bit.names().len(), + 1, + "bit 1 << {shift} is declarable but has no name in KNOWN" + ); + } + } } diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index f63c2f3eecf..3e0af1c3869 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -381,12 +381,12 @@ impl PlatformWalletManager

{ } /// Synchronous twin of [`Self::get_wallet`] for FFI entry points that - /// need to clone the `Arc` out before doing network work - /// outside the handle-storage guard. + /// need to clone the `Arc` out before doing network + /// work outside the handle-storage guard. /// - /// Named `_blocking` for the callers it serves, not for what it does: the - /// wallets map is an `ArcSwap`, so this load is wait-free and cannot block - /// or panic inside a runtime the way the previous `blocking_read` could. + /// The map is an `ArcSwap`, so this load is wait-free and cannot block + /// or panic — the name is kept for source compatibility with the + /// callers that predate that change. pub fn get_wallet_blocking(&self, wallet_id: &WalletId) -> Option> { self.wallets.load().get(wallet_id).cloned() } @@ -1197,7 +1197,7 @@ fn tx_record_snapshot(rec: &TransactionRecord) -> AccountTransactionSnapshot { mod spv_rescan_tests { use std::sync::Arc; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1244,8 +1244,7 @@ mod spv_rescan_tests { Arc::new(NoopPersister), event_handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let wallet = manager .create_wallet_from_seed_bytes( Network::Testnet, diff --git a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs index 7c1b45e1d7e..ca35adb4dbc 100644 --- a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs @@ -525,7 +525,7 @@ impl std::fmt::Debug for DashPaySyncManager { mod tests { use super::*; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -585,8 +585,7 @@ mod tests { /// registry, which is exactly the case that registry-driven DashPay /// sync would skip. async fn register_test_wallet(manager: &Arc>) -> WalletId { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index e95c712c338..93482f4ea78 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -339,14 +339,14 @@ pub struct PlatformWalletManager { /// coalesces, so a snapshot dropped during a lifecycle write would /// be lost for good (see `BalanceUpdateHandler`). Writers are the /// rare manager lifecycle paths (create/remove/load) and publish - /// via `rcu`. That closure can run more than once under a - /// concurrent-writer retry, and only the invocation whose - /// compare-and-swap succeeds is published — so captured state it - /// writes must be OVERWRITTEN per attempt, never accumulated across - /// them. `remove_wallet`'s generation verdict and the load rollback's - /// reclaimed-id list both rely on exactly that: each attempt recomputes - /// its answer from the map the closure was handed, which is the same - /// map the CAS compares against. + /// via `rcu`. Its closure must stay pure map manipulation because it + /// can run more than once under a concurrent-writer retry, and only + /// the invocation whose compare-and-swap succeeds is published — so + /// captured state it writes must be overwritten per attempt, never + /// accumulated across them. `remove_wallet`'s generation verdict and + /// the load rollback's reclaimed-id list both rely on exactly that: + /// each attempt recomputes its answer from the map the closure was + /// handed, which is the same map the CAS compares against. pub(super) wallets: Arc>>>, /// Notified on InstantLock / ChainLock events for `AssetLockManager` waiters. @@ -1373,25 +1373,24 @@ mod tests { ); } - /// A balance snapshot delivered while a lifecycle write to the - /// `wallets` map is in flight must still land in the wallet's - /// lock-free balance atomics. The event bus neither retries nor - /// coalesces, so a snapshot dropped here is gone for good: the - /// wallet keeps displaying the superseded totals until some later - /// event happens to carry a fresh balance, and nothing guarantees - /// one arrives. + /// A `TransactionsSwept` snapshot delivered while a lifecycle write to + /// the `wallets` map is in flight must still land in the wallet's + /// lock-free balance atomics. A sweep can be the ONLY event carrying + /// the corrected (lower) balance — `superseded_by` need not be + /// wallet-relevant, so no later balance-bearing event is guaranteed — + /// and the event bus does not retry or coalesce, so dropping this one + /// snapshot leaves the removed funds on screen indefinitely. /// /// When the map was a `tokio::sync::RwLock` and the handler used - /// `try_read()`, this exact delivery-under-contention scenario - /// dropped the snapshot (the pre-fix form of this test held - /// `wallets.write()` across the delivery and failed). With the map - /// an `ArcSwap`, the closest reachable window is a lifecycle writer - /// parked mid-`rcu`; the handler's `load()` must observe a committed - /// map and apply the balance immediately, before that writer - /// completes. + /// `try_read()`, this exact delivery-under-contention scenario dropped + /// the snapshot (the pre-fix form of this test held `wallets.write()` + /// across the delivery and failed). With the map an `ArcSwap`, the + /// closest reachable window is a lifecycle writer parked mid-`rcu`; + /// the handler's `load()` must observe a committed map and apply the + /// balance immediately, before that writer completes. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn balance_snapshot_survives_wallets_map_write_contention() { - use std::collections::BTreeMap; + async fn swept_balance_survives_wallets_map_write_contention() { + use dashcore::hashes::Hash; use crate::test_support::test_platform_wallet_manager; use crate::wallet::core::BalanceUpdateHandler; @@ -1415,12 +1414,8 @@ mod tests { let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); let wallets_for_writer = Arc::clone(&manager.wallets); let writer = std::thread::spawn(move || { - // `rcu` re-runs its closure if the compare-and-swap loses, so the - // release must be waited on ONCE: a second `recv()` would block - // forever on a channel the test only sends to once, and - // `writer.join()` below would hang the suite instead of failing - // it. Nothing else writes this map today, so the retry is latent - // — which is exactly why it must not be able to wedge the test. + // `rcu` may re-run the closure after a compare-and-swap loss. Wait + // for the release only once so a retry cannot wedge this test. let mut parked = false; wallets_for_writer.rcu(|current| { if !parked { @@ -1435,26 +1430,24 @@ mod tests { .recv() .expect("the writer must reach its rcu closure"); - // Deliver the balance-bearing event while the write is in flight. + // Deliver the sweep while the write is in flight. let corrected = WalletCoreBalance::new(1_234, 0, 0, 0); - handler.on_wallet_event(&crate::events::WalletEvent::BlockProcessed { + handler.on_wallet_event(&crate::events::WalletEvent::TransactionsSwept { wallet_id, - height: 1_000, - chain_lock: None, - inserted: vec![], - updated: vec![], - matured: vec![], + txids: vec![dashcore::Txid::from_byte_array([0xAA; 32])], + superseded_by: dashcore::Txid::from_byte_array([0xBB; 32]), + winner_mined_height: None, + released_outpoints: vec![], balance: corrected, - account_balances: BTreeMap::new(), - addresses_derived: vec![], + account_balances: Default::default(), }); // Observable immediately — before the lifecycle writer commits. assert_eq!( wallet.balance().confirmed(), corrected.confirmed(), - "the balance snapshot was dropped: a lifecycle write to the wallets map \ - was in flight during delivery, and the bus will not re-deliver it" + "the swept balance snapshot was dropped: a lifecycle write to the wallets map \ + was in flight during delivery, and no later event will re-carry the correction" ); release_tx.send(()).expect("writer still parked"); diff --git a/packages/rs-platform-wallet/src/manager/startup.rs b/packages/rs-platform-wallet/src/manager/startup.rs index 7d19ee1a959..7a83bc4787e 100644 --- a/packages/rs-platform-wallet/src/manager/startup.rs +++ b/packages/rs-platform-wallet/src/manager/startup.rs @@ -1375,8 +1375,8 @@ mod tests { } fn seed_for(phrase: &str) -> [u8; 64] { - use key_wallet::mnemonic::{Language, Mnemonic}; - Mnemonic::from_phrase(phrase, Language::English) + use key_wallet::mnemonic::Mnemonic; + Mnemonic::from_phrase(phrase) .expect("valid test mnemonic") .to_seed("") } diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 2de7cc831fa..f2344e81f33 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use dash_spv::chain::CheckpointManager; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -22,33 +22,10 @@ use crate::wallet::PlatformWallet; use super::PlatformWalletManager; -/// Parse a BIP-39 mnemonic against every supported wordlist in turn, -/// returning the first language that yields a valid mnemonic. -/// -/// `key_wallet::Mnemonic` only exposes language-tagged constructors, -/// so callers that take a user-supplied mnemonic must walk the -/// language list themselves to avoid rejecting non-English phrases as -/// "invalid English". BIP-39 wordlists are mutually exclusive per -/// phrase, so the first match is unambiguous. +/// Parse a BIP-39 mnemonic using the wordlist auto-detection provided by +/// `key_wallet::Mnemonic`. fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Test-only rendezvous fired inside [`PlatformWalletManager::remove_wallet_with_teardown`], @@ -923,7 +900,7 @@ impl PlatformWalletManager

{ #[cfg(test)] mod scoped_wallet_id_tests { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -934,8 +911,7 @@ mod scoped_wallet_id_tests { abandon abandon abandon abandon abandon about"; fn wallet_id_for(network: Network) -> [u8; 32] { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction"); @@ -950,8 +926,7 @@ mod scoped_wallet_id_tests { /// "Networks" section can group a seed's sibling-network wallets. /// Mirrors the `register_wallet` derivation exactly. fn wallet_group_id_for(network: Network) -> [u8; 32] { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction"); @@ -1034,7 +1009,7 @@ mod scoped_wallet_id_tests { mod register_wallet_duplicate_tests { use std::sync::Arc; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1100,8 +1075,7 @@ mod register_wallet_duplicate_tests { let manager = make_manager(); let network = Network::Testnet; - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // First registration succeeds. `Some(0)` skips the SPV-tip @@ -1161,7 +1135,7 @@ mod register_wallet_duplicate_tests { let manager = make_manager(); let network = Network::Testnet; - let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid test mnemonic") .to_seed(""); @@ -1239,7 +1213,7 @@ mod register_wallet_duplicate_tests { use dashcore::{OutPoint, ScriptBuf, Transaction, TxIn, Txid, Witness}; let manager = make_manager(); - let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid test mnemonic") .to_seed(""); @@ -1305,7 +1279,7 @@ mod remove_versus_recreate_tests { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1424,8 +1398,8 @@ mod remove_versus_recreate_tests { if already_fired { return; } - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid test mnemonic"); + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // The real registration path: inner `WalletManager` first, // then `self.wallets`. `Some(0)` skips the SPV-tip lookup. diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index a9dbddba98c..854d6c59d81 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -667,7 +667,7 @@ pub async fn test_platform_wallet_manager() -> ( Arc>, WalletId, ) { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; // Canonical all-`abandon` BIP-39 test vector. @@ -684,8 +684,7 @@ pub async fn test_platform_wallet_manager() -> ( event_handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // `Some(0)` skips the SPV birth-height lookup so the create never hits the // network. @@ -733,9 +732,9 @@ pub(crate) async fn mnemonic_wallet_manager( ) { use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::ManagedWalletInfo; - use key_wallet::{Language, Mnemonic}; + use key_wallet::Mnemonic; - let mnemonic = Mnemonic::from_phrase(phrase, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(phrase).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic( mnemonic, Network::Testnet, 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 64b6414c07b..a14417dba53 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 @@ -358,6 +358,73 @@ pub(crate) async fn reconstruct_tracked_asset_locks( cs } +/// `TransactionsSwept` sibling of the hooks above: drop every tracked +/// entry whose funding transaction the sweep just removed. +/// +/// A swept funding tx was provably beaten to one of its inputs, so it can +/// never confirm and its credit outputs will never be usable — but nothing +/// else ever cascades the removal into this table. Left alone, the entry +/// is a zombie the resume path re-broadcasts and then waits on without +/// bound, and the persisted mirror carries it forever. The changeset's +/// `removed` set is the same deletion channel a rejected-at-broadcast +/// `Built` row uses, and every store already applies it. +/// +/// Removal is safe against the one way the verdict can reverse: a +/// chainlocked return re-emits the funding record through +/// `TransactionDetected` / `BlockProcessed`, and reconstruction re-inserts +/// the entry from it — the same path a restore scan uses. +/// +/// The tracked map is inspected under the write lock (sweeps are rare and +/// carry few txids, so there is no hot path to protect), and untouched +/// wallets return an empty changeset without allocating. +/// +/// Deliberately NO rejection undo, unlike the sweep's payment flips: if +/// the round this changeset rides is rejected, the in-memory entry is +/// gone while the mirror row survives — a session-local divergence only. +/// The rejection faults the wallet, the frozen watermark already forces +/// the restart, and `load()` there re-syncs from the mirror while the +/// re-scan re-emits the sweep (rejected rounds keep the loser's record) +/// and re-drops the entry — or re-inserts it through reconstruction if +/// the funding tx turned out to live. An undo ledger would buy nothing +/// that restart does not already guarantee. +pub(crate) async fn remove_tracked_asset_locks_for_swept( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + swept: &[dashcore::Txid], +) -> AssetLockChangeSet { + let mut cs = AssetLockChangeSet::default(); + if swept.is_empty() { + return cs; + } + // Hashed once, before the write lock: the loser slice is sized by the + // network (the mempool alone tracks up to a thousand conflicts), and a + // linear `contains` per tracked entry would put O(entries × losers) + // work under the wallet-manager write lock. + let swept: std::collections::HashSet = swept.iter().copied().collect(); + let mut wm = wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(wallet_id) else { + return cs; + }; + if info.tracked_asset_locks.is_empty() { + return cs; + } + let dead: Vec = info + .tracked_asset_locks + .keys() + .filter(|out_point| swept.contains(&out_point.txid)) + .copied() + .collect(); + for out_point in dead { + info.tracked_asset_locks.remove(&out_point); + cs.removed.insert(out_point); + tracing::info!( + outpoint = %out_point, + "dropped tracked asset lock — its funding transaction was swept" + ); + } + cs +} + /// One record's full reconstruction step: insert-if-absent, then let a /// finalized record upgrade what's already tracked but still unproven /// (the inserts carry their own proof already, so enrichment only ever diff --git a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs index 1fb87a0d1de..c6b66165c9a 100644 --- a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs @@ -33,11 +33,13 @@ use crate::wallet::PlatformWallet; /// manager lifecycle write (wallet insert / remove / load) is publishing /// a new one. That infallibility is load-bearing, not a convenience. /// `on_wallet_event` is synchronous and the bus neither retries nor -/// coalesces, so a snapshot missed here is gone for good: nothing -/// guarantees a later event carries the same correction, and until one -/// does the wallet displays superseded totals. A fallible lookup (the -/// previous `RwLock::try_read`) dropped exactly that snapshot whenever -/// it raced a lifecycle write. +/// coalesces, so a snapshot missed here is gone for good — and +/// `TransactionsSwept` can be the *only* event carrying a corrected +/// (lower) balance, since the winner that settled the inputs need not be +/// wallet-relevant and so may never produce a later balance-bearing +/// event. A fallible lookup (the previous `RwLock::try_read`) dropped +/// exactly that snapshot when it raced a lifecycle write, leaving +/// removed funds on display indefinitely. pub struct BalanceUpdateHandler { wallets: Arc>>>, } @@ -59,6 +61,15 @@ impl EventHandler for BalanceUpdateHandler { } | WalletEvent::BlockProcessed { wallet_id, balance, .. + } + // A sweep is the one event that can lower the balance: the + // removed transactions' outputs are gone from the UTXO set. + // The snapshot it carries is post-removal, like every other + // variant's, so it routes identically — dropping it would + // leave the corrected-away amount on screen until the next + // balance-bearing event happened to arrive. + | WalletEvent::TransactionsSwept { + wallet_id, balance, .. } => (wallet_id, balance), // No balance on SyncHeightAdvanced — checkpoint advance only. WalletEvent::SyncHeightAdvanced { .. } => return, @@ -69,15 +80,11 @@ impl EventHandler for BalanceUpdateHandler { WalletEvent::ChainLockProcessed { .. } => return, }; - // Wait-free snapshot of the wallets map; cannot fail or block, so - // a lifecycle write cannot cost a snapshot. A wallet absent - // from the snapshot is one still inside its creation window: it is - // registered in the inner manager (and therefore SPV-visible) - // several `.await`s before it is published here. Both creation - // paths close that window by re-seeding the atomics from the inner - // wallet immediately after publishing — `register_wallet` and - // `load_from_persistor` alike — so the window costs a snapshot, not - // the balance. + // Wait-free snapshot of the wallets map; cannot fail or block, + // so no balance-bearing event is ever dropped here. A wallet + // not in the snapshot is one registered concurrently with this + // event — its creation path re-seeds the balance atomics from + // the inner wallet after publishing it, covering that window. let wallets = self.wallets.load(); if let Some(pw) = wallets.get(wallet_id) { pw.balance().set( diff --git a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs index 57810c5dfd2..72e60ea6c30 100644 --- a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs +++ b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs @@ -136,7 +136,8 @@ fn observing_wallet(event: &WalletEvent) -> Option<&WalletId> { | WalletEvent::BlockProcessed { wallet_id, .. } => Some(wallet_id), WalletEvent::TransactionInstantLocked { .. } | WalletEvent::ChainLockProcessed { .. } - | WalletEvent::SyncHeightAdvanced { .. } => None, + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::TransactionsSwept { .. } => None, } } @@ -165,12 +166,13 @@ pub(crate) fn observed_spends(event: &WalletEvent) -> Vec { inserted.iter().flat_map(spent_outpoints).collect() } // Finality promotions of records the wallet already holds, and a bare - // watermark advance. No new spend in any of them — and note that the - // watermark is precisely the "chain moved" signal that must NOT touch - // a fence. + // watermark advance carry no new spend. A sweep carries removals and + // released outpoints, not a newly observed spend. The watermark is + // precisely the "chain moved" signal that must not touch a fence. WalletEvent::TransactionInstantLocked { .. } | WalletEvent::ChainLockProcessed { .. } - | WalletEvent::SyncHeightAdvanced { .. } => Vec::new(), + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::TransactionsSwept { .. } => Vec::new(), } } @@ -329,6 +331,25 @@ mod tests { assert_eq!(spends, [a, b]); } + /// A sweep reports transactions that lost conflict resolution and + /// outpoints it released. Neither is a newly observed spend, so the event + /// must not retire an in-broadcast fence. + #[test] + fn swept_transactions_do_not_report_spend_observations() { + let event = WalletEvent::TransactionsSwept { + wallet_id: WALLET_ID, + txids: vec![Txid::from_slice(&[8u8; 32]).expect("valid txid")], + superseded_by: Txid::from_slice(&[9u8; 32]).expect("valid txid"), + winner_mined_height: None, + released_outpoints: vec![outpoint(10)], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }; + + assert!(observed_spends(&event).is_empty()); + assert!(observing_wallet(&event).is_none()); + } + /// THE VARIANT THAT MUST NEVER TOUCH A FENCE. /// /// `SyncHeightAdvanced` is the bare "the chain moved" watermark, and it is 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 ca7cd2797ed..0cb6f0db279 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 @@ -5461,7 +5461,7 @@ mod contact_info_provider_tests { use crate::wallet::identity::crypto::contact_info::derive_contact_info_keys; use crate::wallet::identity::network::identity_auth_derivation_path_for_type; use key_wallet::bip32::KeyDerivationType; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::Network; // Canonical BIP-39 test mnemonic. @@ -5477,7 +5477,7 @@ mod contact_info_provider_tests { /// open round-trips. #[tokio::test] async fn contact_info_seal_open_matches_resident_derivation_at_real_auth_path() { - let seed = Mnemonic::from_phrase(PHRASE, Language::English) + let seed = Mnemonic::from_phrase(PHRASE) .expect("valid mnemonic") .to_seed(""); let network = Network::Testnet; @@ -5560,7 +5560,7 @@ mod contact_info_provider_tests { async fn ecdh_shared_secret_returns_zeroizing_matching_resident_derivation() { use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; - let seed = Mnemonic::from_phrase(PHRASE, Language::English) + let seed = Mnemonic::from_phrase(PHRASE) .expect("valid mnemonic") .to_seed(""); let network = Network::Testnet; @@ -5662,7 +5662,7 @@ mod stamp_race_tests { use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use dpp::identity::v0::IdentityV0; use dpp::identity::Identity; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; use std::collections::BTreeMap; @@ -5698,8 +5698,7 @@ mod stamp_race_tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index d18f1fb5cd4..8c66dbd695d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -851,7 +851,7 @@ mod tests { use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::Network; use std::collections::BTreeMap; @@ -859,7 +859,7 @@ mod tests { abandon abandon abandon abandon abandon about"; fn test_master() -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master xpriv") } 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 4f0a3a51c1e..402b6930737 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 @@ -479,7 +479,7 @@ impl IdentityWallet { mod tests { use super::*; use dpp::util::hash::ripemd160_sha256; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -497,8 +497,7 @@ mod tests { /// touches — the identity-auth derivation walks the master xpriv, /// not the per-account collection, so no accounts are needed. fn mnemonic_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::None) .expect("from_mnemonic should build a Mnemonic wallet") } @@ -508,8 +507,7 @@ mod tests { /// (`RootExtendedPrivKey::new_master(seed).to_extended_priv_key(network)` /// is byte-for-byte `ExtendedPrivKey::new_master(network, seed)`). fn master_for(network: Network) -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(network, &seed).expect("master xpriv from test seed") } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index 0027446e5ba..76ccd55ce9e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -1490,7 +1490,7 @@ mod tests { use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; use crate::wallet::persister::NoPlatformPersistence; use crate::PlatformWalletError; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::signer::{Signer, SignerMethod}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1561,8 +1561,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index bdbde9b0850..ecc7897ae4b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -527,7 +527,7 @@ mod tests { }; use super::{derive_load_probe_hash, ResolvedLoadKeyHashSource}; use key_wallet::bip32::ExtendedPrivKey; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -545,8 +545,7 @@ mod tests { /// never touches — it walks the master xpriv, not the per-account /// collection, so no accounts are needed. fn mnemonic_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::None) .expect("from_mnemonic should build a Mnemonic wallet") } @@ -554,8 +553,7 @@ mod tests { /// The BIP-32 master node for [`TEST_MNEMONIC`] on `network` — the /// same node `derive_extended_private_key` reconstructs internally. fn master_for(network: Network) -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(network, &seed).expect("master xpriv from test seed") } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs index 62d174bb650..0c3cc79d3d1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs @@ -251,7 +251,16 @@ fn dashpay_payment_records(event: &WalletEvent) -> Vec<&TransactionRecord> { WalletEvent::BlockProcessed { inserted, updated, .. } => inserted.iter().chain(updated.iter()).collect(), + // `TransactionsSwept` carries txids, not records: the wallet has + // already dropped the records these name. Its payment consequence + // — failing the matching `Pending` sent payments, since a swept + // transaction can never confirm — is NOT this handler's to apply: + // a sweep never re-emits once its round is durable, so the flip + // must ride the sweep's own atomic store round, which belongs to + // the wallet-event adapter. Routing it here would persist the + // flip on a separate round with no replay if that round fails. WalletEvent::TransactionInstantLocked { .. } + | WalletEvent::TransactionsSwept { .. } | WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => Vec::new(), } @@ -274,16 +283,25 @@ fn drives_payment_hooks(event: &WalletEvent) -> bool { WalletEvent::BlockProcessed { inserted, updated, .. } => !inserted.is_empty() || !updated.is_empty(), - WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => false, + // No records to route (see `dashpay_payment_records`), so a task + // here would take and release the wallet-manager write lock for + // nothing. The sweep's payment consequence belongs on the + // wallet-event adapter's own store round — see `dashpay_payment_records`. + WalletEvent::TransactionsSwept { .. } + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::ChainLockProcessed { .. } => false, } } /// Run the DashPay payment hooks for `event`: record any incoming DashPay /// payment, then advance a matching sent payment from `Pending` to /// `Confirmed` once its transaction reaches finality (mined or -/// InstantSend-locked). All paths are idempotent per txid, so re-detections -/// and repeated block-processing rounds converge without duplicating -/// entries. +/// InstantSend-locked). The opposite terminal — `Failed`, when a sweep +/// proves the transaction never can confirm — is deliberately not applied +/// here: it belongs on the sweep's own atomic store round in the +/// wallet-event adapter (see `dashpay_payment_records`). All paths are +/// idempotent per txid, so re-detections and repeated block-processing +/// rounds converge without duplicating entries. pub(crate) async fn run_dashpay_payment_hooks( wallet_manager: &Arc>>, wallet_id: &WalletId, @@ -458,6 +476,28 @@ mod tests { assert!(drives_payment_hooks(&event)); } + /// `TransactionsSwept` must NOT drive the payment hooks: its payment + /// consequence — failing the losers' `Pending` sent payments — belongs + /// on the wallet-event adapter's own atomic store round, because a + /// sweep never re-emits once its round is durable and a separately + /// persisted flip that failed its store would be lost for good. + /// Spawning a hook task here would race a second write against that + /// round. + #[test] + fn transactions_swept_does_not_drive_payment_hooks() { + let event = WalletEvent::TransactionsSwept { + wallet_id: [0u8; 32], + txids: vec![dashcore::Txid::from([0x21; 32])], + superseded_by: dashcore::Txid::from([0x22; 32]), + winner_mined_height: None, + released_outpoints: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + }; + assert!(dashpay_payment_records(&event).is_empty()); + assert!(!drives_payment_hooks(&event)); + } + /// A `BlockProcessed` that changed no records (syncing past an empty /// block) has no payment work, so it must not spawn a hook task. Pins /// the spawn-skip that keeps initial sync from taking the wallet-manager 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 43e001ba0ad..4c455aba433 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1639,7 +1639,7 @@ mod tests { use dpp::prelude::Identifier; use key_wallet::account::account_collection::DashpayAccountKey; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1876,8 +1876,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1909,8 +1908,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1945,8 +1943,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1972,7 +1969,7 @@ mod tests { owner: &Identifier, contact: &Identifier, ) -> key_wallet::bip32::ExtendedPubKey { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet = key_wallet::wallet::Wallet::from_seed_bytes( @@ -2592,8 +2589,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -4768,7 +4764,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -5074,8 +5070,7 @@ mod tests { // The signer's seed (the faithful test stand-in derives from it). let seed = { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); mnemonic.to_seed("") }; @@ -5219,7 +5214,7 @@ mod tests { let watched = Identifier::from([0x42; 32]); let contact = Identifier::from([0x22; 32]); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); @@ -5346,7 +5341,7 @@ mod tests { Arc::clone(&persister), handler, )); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet_id = manager @@ -5491,7 +5486,7 @@ mod tests { } let provider = SeedCryptoProvider::from_seed( - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""), Network::Testnet, @@ -5567,7 +5562,7 @@ mod tests { ) .expect("auth path at the legacy key id"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -5714,7 +5709,7 @@ mod tests { Arc::clone(&persister), handler, )); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet_id = manager @@ -5945,7 +5940,7 @@ mod tests { let (manager, _persister, wallet_id) = make_watch_only_wallet().await; let iw = manager.get_wallet(&wallet_id).await.expect("wallet"); let iw = iw.identity(); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6053,7 +6048,7 @@ mod tests { // so the send fails AFTER the drain has run. let pay_contact = Identifier::from([0x22; 32]); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); @@ -6158,7 +6153,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6191,7 +6186,7 @@ mod tests { .await .expect("register external account"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6267,7 +6262,7 @@ mod tests { // The sending side, so the external-account lookup passes. let shared_key = [0x55u8; 32]; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let compact = { @@ -6352,7 +6347,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6385,7 +6380,7 @@ mod tests { .await .expect("register external account"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6466,7 +6461,7 @@ mod tests { .expect("register receiving account"); plant_receival_utxo(&manager, wallet_id, owner_id, contact_id, 0xC2, 60_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6514,7 +6509,7 @@ mod tests { // broadcast (and its preceding used-flip persist). fund_bip44_account_0(&manager, wallet_id, 0xB7, 120_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6689,7 +6684,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6732,7 +6727,7 @@ mod tests { // broadcast (a funding-build failure returns before it). fund_bip44_account_0(&manager, wallet_id, 0xA1, 60_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6817,7 +6812,7 @@ mod tests { vout: 0, }; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6916,7 +6911,7 @@ mod tests { vout: 0, }; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -7028,7 +7023,7 @@ mod tests { vout: 0, }; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -7325,7 +7320,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -7385,7 +7380,7 @@ mod tests { let funded = amount + 526; fund_bip44_account_0(&manager, wallet_id, 0xA1, funded).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -7440,7 +7435,7 @@ mod tests { let funded = amount + 1226; fund_bip44_account_0(&manager, wallet_id, 0xB2, funded).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs index 10cc57c35c2..30cbc92a253 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs @@ -613,7 +613,7 @@ mod tests { use std::sync::Arc; use std::time::{Duration, Instant}; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -659,7 +659,7 @@ mod tests { } fn seed_for(phrase: &str) -> [u8; 64] { - Mnemonic::from_phrase(phrase, Language::English) + Mnemonic::from_phrase(phrase) .expect("valid test mnemonic") .to_seed("") } diff --git a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs index 9044fc23a5b..1ec868f5e33 100644 --- a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs +++ b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs @@ -715,7 +715,7 @@ impl PlatformWallet { #[cfg(test)] mod tests { use super::*; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -732,15 +732,13 @@ mod tests { "legal winner thank year wave sausage worth useful legal winner thank yellow"; fn seed_bearing_wallet(network: Network) -> Wallet { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction") } fn second_seed_bearing_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC_B, Language::English) - .expect("valid test mnemonic B"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC_B).expect("valid test mnemonic B"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet B construction") } diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index b0c1a3c4d3c..e6a9367c4ab 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -943,7 +943,7 @@ mod tests { #[tokio::test] async fn extended_public_key_matches_wallet_derivation_for_dashpay_path() { use key_wallet::account::AccountType; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -960,8 +960,7 @@ mod tests { .expect("DashPay receiving path"); // Old route: resident-seed wallet from the same mnemonic. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -999,7 +998,7 @@ mod tests { /// this pins them equal. #[tokio::test] async fn ecdh_shared_secret_matches_wallet_derivation() { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -1012,8 +1011,7 @@ mod tests { // Old route: resident-seed wallet from the same mnemonic → derive the // scalar at `path` → ECDH through the single crypto source. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -1053,7 +1051,7 @@ mod tests { /// this pins the signer route equal to `Wallet`'s and confirms the inverse. #[tokio::test] async fn account_reference_matches_wallet_derivation_and_round_trips() { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -1065,8 +1063,7 @@ mod tests { // Old route: resident-seed wallet from the same mnemonic → derive the // scalar at `path` → mask through the single accountReference source. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -1117,7 +1114,7 @@ mod tests { /// so contactInfo the signer seals is readable by the reference clients. #[tokio::test] async fn contact_info_seal_open_round_trips_and_matches_wallet_derivation() { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -1153,8 +1150,7 @@ mod tests { ); // Parity: encToUserId equals a resident wallet's derive+encrypt. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) diff --git a/packages/rs-sdk-ffi/src/signer_simple.rs b/packages/rs-sdk-ffi/src/signer_simple.rs index 1eb97522e5a..f2a1983ef2f 100644 --- a/packages/rs-sdk-ffi/src/signer_simple.rs +++ b/packages/rs-sdk-ffi/src/signer_simple.rs @@ -12,42 +12,14 @@ use zeroize::Zeroizing; // `Runtime::new().block_on(...)`. use dash_async::block_on; -/// Parse a BIP-39 mnemonic phrase against every supported wordlist -/// in turn, returning the first language that yields a valid mnemonic. -/// -/// `key_wallet::Mnemonic` only exposes language-tagged constructors -/// (`Mnemonic::from_phrase(phrase, lang)`); the upstream `bip39` crate -/// has a `Mnemonic::parse(phrase)` that auto-detects, but it is not -/// re-exported through the `key_wallet` wrapper. Until that helper is -/// surfaced upstream, callers who accept user-supplied mnemonics need -/// to walk the language list themselves so a French / Japanese / etc. -/// phrase is not rejected as "invalid English". -/// -/// BIP-39 wordlists are designed to be mutually exclusive within a -/// single phrase, so the first match is unambiguous. +/// Parse a BIP-39 mnemonic phrase using the wordlist auto-detection provided +/// by `key_wallet::Mnemonic`. pub(crate) fn parse_mnemonic_any_language( phrase: &str, ) -> Result { - use key_wallet::mnemonic::{Language, Mnemonic}; - - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + use key_wallet::mnemonic::Mnemonic; + + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Create a signer from a private key. diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 917d26094df..ed016f76a0b 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -56,11 +56,11 @@ use platform_wallet_ffi::{ AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, ContactRequestRemovalFFI, CoreAddressEntryFFI, DpnsNameStateFFI, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, - InvitationEntryFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, + InvitationEntryFFI, OutPointFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, PersistenceCallbacksExtension, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, - SpentOutPointFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, - UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, - WalletRestoreEntryFFI, + SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, + TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, + WalletChangeSetFFI, WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -198,6 +198,10 @@ pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks { pub(crate) fn build_extension() -> PersistenceCallbacksExtension { PersistenceCallbacksExtension { on_persist_dpns_name_states_fn: Some(tramp_persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: Some(tramp_persist_wallet_changeset_sweeps), + on_persist_wallet_changeset_chain_lock_height_fn: Some( + tramp_persist_wallet_changeset_chain_lock_height, + ), ..Default::default() } } @@ -637,10 +641,157 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( return Ok(code); } } + + Ok(0) + }) +} + +/// Extension-callback trampoline for the round's sweep batches. These used +/// to ride at the tail of [`WalletChangeSetFFI`]; they now arrive through +/// `PersistenceCallbacksExtension`'s size-negotiated sweep slot (the bare +/// changeset pointer cannot prove to a consumer that its producer allocated +/// a tail field — see the layout note on that struct). Native fires this +/// right after `tramp_persist_wallet_changeset` in the same round, so the +/// Kotlin bridge still sees records before removals. +/// +/// One bridge call per batch, in order: a later sweep can keep a coin spent +/// that an earlier one freed, and only replaying them in sequence preserves +/// that. Each call does its own hold-then-release, so the ordering holds on +/// the Kotlin side too. The batch count is not bounded by this ABI, so — +/// as with the account loop in the changeset trampoline — the whole +/// per-batch construction and call runs inside its own local frame; +/// without it, `byte_array_cls`, `empty`, and the three per-batch arrays +/// would all pile up in the trampoline's own frame across every batch, and +/// a large enough round can exhaust ART's local-reference table before the +/// callback ever returns. +unsafe extern "C" fn tramp_persist_wallet_changeset_sweeps( + context: *mut c_void, + wallet_id: *const u8, + sweeps: *const SweepBatchFFI, + sweeps_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + for batch in slice_or_empty(sweeps, sweeps_count) { + let code = env.with_local_frame(16, |env| { + persist_changeset_sweep_batch(env, bridge, &wid, batch) + })?; + if code != 0 { + return Ok(code); + } + } Ok(0) }) } +unsafe fn persist_changeset_sweep_batch( + env: &mut JNIEnv, + bridge: &JObject, + wid: &JByteArray, + batch: &SweepBatchFFI, +) -> Result { + let byte_array_cls = env.find_class("[B")?; + let empty = env.byte_array_from_slice(&[])?; + + let txids = slice_or_empty(batch.txids, batch.txids_count); + let txids_arr = env.new_object_array(txids.len() as i32, &byte_array_cls, &empty)?; + // The winner is invariant for the whole batch, so it is allocated once + // and every slot is initialised to it — `new_object_array` fills the + // array with its initial element, so no per-loser set is needed either. + // The loser count is network-influenced and this projection runs + // synchronously inside the atomic persistence callback, so a per-loser + // allocation is work an attacker can scale. Sharing one array across + // the slots is safe because the Kotlin consumer only ever reads these + // values: `supersededBy[i]` feeds DAO arguments and entity fields, and + // nothing writes into the array. + let winner = env.byte_array_from_slice(&batch.superseded_by)?; + let winners = env.new_object_array(txids.len() as i32, &byte_array_cls, &winner)?; + for (i, txid) in txids.iter().enumerate() { + env.with_local_frame(8, |env| { + let t = env.byte_array_from_slice(txid)?; + env.set_object_array_element(&txids_arr, i as i32, &t) + })?; + } + + // Released outpoints ride as 36-byte keys (raw txid + a + // little-endian vout), the shape the handler stores them in. + let released = slice_or_empty(batch.released_outpoints, batch.released_outpoints_count); + let released_arr = env.new_object_array(released.len() as i32, &byte_array_cls, &empty)?; + for (i, outpoint) in released.iter().enumerate() { + let key = pack_outpoint_key(outpoint); + env.with_local_frame(4, |env| { + let k = env.byte_array_from_slice(&key)?; + env.set_object_array_element(&released_arr, i as i32, &k) + })?; + } + + // Inputs the batch vouches for on behalf of a loser this store never + // held (swept before its detection reached persistence) — same 36-byte + // key shape as the released set, settled by the handler exactly like a + // deleted row's own inputs. + let claimed = slice_or_empty(batch.claimed_inputs, batch.claimed_inputs_count); + let claimed_arr = env.new_object_array(claimed.len() as i32, &byte_array_cls, &empty)?; + for (i, outpoint) in claimed.iter().enumerate() { + let key = pack_outpoint_key(outpoint); + env.with_local_frame(4, |env| { + let k = env.byte_array_from_slice(&key)?; + env.set_object_array_element(&claimed_arr, i as i32, &k) + })?; + } + + // The winner's finality context: its mined height for a block-context + // sweep, -1 for an InstantSend-locked winner still waiting to be mined. + // The sentinel is unambiguous — block heights are non-negative — and + // the Kotlin bridge maps it back to null. The handler keys a + // pending-input tombstone's LIFETIME on it, never its existence: every + // non-released input keeps a durable claim in either context, stamped + // and collectible at the chainlock finality boundary when the winner + // mined, unstamped and held until resolved by proof (funding arrival, + // a later block-context re-stamp, or a release) when it did not. + let winner_mined_height: i32 = if batch.has_winner_mined_height { + batch.winner_mined_height as i32 + } else { + -1 + }; + + env.call_method( + bridge, + "onWalletChangesetTransactionsSwept", + "([B[[B[[B[[B[[BI)I", + &[ + wid.into(), + (&txids_arr).into(), + (&winners).into(), + (&released_arr).into(), + (&claimed_arr).into(), + JValue::Int(winner_mined_height), + ], + )? + .i() +} + +/// Deliver the round's numeric chainlock height (see +/// `PersistWalletChangesetChainLockHeightFn`). One scalar, one call — the +/// bincode chainlock blob on the header call is opaque to Kotlin, and this +/// is the half of the tombstone-collection boundary +/// `min(chainlockHeight, syncedHeight)` the handler cannot otherwise know. +unsafe extern "C" fn tramp_persist_wallet_changeset_chain_lock_height( + context: *mut c_void, + wallet_id: *const u8, + chain_lock_height: u32, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + env.call_method( + bridge, + "onWalletChangesetChainLockHeight", + "([BI)I", + &[(&wid).into(), JValue::Int(chain_lock_height as i32)], + )? + .i() + }) +} + unsafe fn persist_changeset_account( env: &mut JNIEnv, bridge: &JObject, @@ -674,6 +825,26 @@ unsafe fn persist_changeset_account( return Ok(code); } + // Transactions before their UTXOs — matches the Swift bridge's + // `applyAccountChangeset` order (transactions, then utxos_added, then + // utxos_spent) and, since the sweep-reinstatement fix, is load-bearing + // here too: `onWalletChangesetUtxoAdded` bails when its parent row is + // still `isGloballySwept`, and `onWalletChangesetTransaction` is what + // clears that flag on a reinstating record. Emitting a reinstated + // transaction's own fresh outputs before its record would have them + // walk straight into that guard and be silently dropped, one round + // before the record that was supposed to unlock them. Ordinary + // first-sighting transactions are unaffected either way — the stub + // row `onWalletChangesetUtxoAdded` creates when no parent exists yet + // still covers any residual cross-account race. + for t in slice_or_empty(acc.transactions, acc.transactions_count) { + let code = env.with_local_frame(40, |env| { + persist_changeset_transaction(env, bridge, wid, acc, t) + })?; + if code != 0 { + return Ok(code); + } + } for u in slice_or_empty(acc.utxos_added, acc.utxos_added_count) { let code = env.with_local_frame(24, |env| persist_changeset_utxo_added(env, bridge, wid, u))?; @@ -688,14 +859,6 @@ unsafe fn persist_changeset_account( return Ok(code); } } - for t in slice_or_empty(acc.transactions, acc.transactions_count) { - let code = env.with_local_frame(40, |env| { - persist_changeset_transaction(env, bridge, wid, acc, t) - })?; - if code != 0 { - return Ok(code); - } - } env.call_method( bridge, @@ -774,15 +937,14 @@ unsafe fn persist_changeset_transaction( let tx_type = cstr(env, t.transaction_type)?; let label = cstr(env, t.label)?; // Input outpoints (one per tx input, in vin order; empty for coinbase). - // Flatten to txid[32] || vout(u32 LE) = 36 bytes each — byte-identical to - // Kotlin/Swift makeOutpoint, so the pending-input join key matches with no - // per-element conversion on the Kotlin side. Dropping these is what left a - // spend-before-funding output restorable as spendable (CORE-06). + // Flattened 36-byte keys (see `pack_outpoint_key`), so the pending-input + // join key matches with no per-element conversion on the Kotlin side. + // Dropping these is what left a spend-before-funding output restorable + // as spendable (CORE-06). let ops = slice_or_empty(t.input_outpoints, t.input_outpoints_count); let mut packed = Vec::with_capacity(ops.len() * 36); for op in ops { - packed.extend_from_slice(&op.txid); - packed.extend_from_slice(&op.vout.to_le_bytes()); + packed.extend_from_slice(&pack_outpoint_key(op)); } let input_outpoints = env.byte_array_from_slice(&packed)?; let input_outpoint_count = ops.len() as i32; @@ -3951,6 +4113,18 @@ unsafe fn slice_or_empty<'a, T>(ptr: *const T, count: usize) -> &'a [T] { } } +/// Pack an [`OutPointFFI`] into the 36-byte key (raw txid ‖ little-endian +/// vout) the Kotlin handler stores outpoints under — byte-identical to +/// Kotlin's `makeOutpoint` (and Swift's). This is the join key sweep +/// releases use to find additive-path rows, so every packing site routes +/// through here rather than re-inlining the layout. +fn pack_outpoint_key(outpoint: &OutPointFFI) -> [u8; 36] { + let mut key = [0u8; 36]; + key[..32].copy_from_slice(&outpoint.txid); + key[32..].copy_from_slice(&outpoint.vout.to_le_bytes()); + key +} + /// `Vec` → `(*const T, len)`; empty vec yields `(null, 0)`. A non-null /// pointer is a leaked `Box<[T]>` the matching load-free trampoline /// reconstructs and drops — mint it only once the whole load succeeded. @@ -4270,6 +4444,18 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ "onWalletChangesetTransaction", WALLET_CHANGESET_TRANSACTION_DESCRIPTOR, ), + // Missing from this table let a sweep-round-only descriptor drift pass + // the smoke check and surface only when a live sweep first called it — + // right where a failed round freezes the wallet's watermark. Descriptor + // must track the literal at the `call_method` site in + // `persist_changeset_sweep_batch` above. + ("onWalletChangesetTransactionsSwept", "([B[[B[[B[[B[[BI)I"), + // Same drift risk as the sweeps descriptor above: this slot fires on + // chainlock-advancing rounds only, so a stale descriptor would surface + // exactly when the first real chainlock crossed. Must track the + // literal at the `call_method` site in + // `tramp_persist_wallet_changeset_chain_lock_height`. + ("onWalletChangesetChainLockHeight", "([BI)I"), ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ diff --git a/packages/swift-sdk/Package.swift b/packages/swift-sdk/Package.swift index 253d84fcccd..496f8eb5408 100644 --- a/packages/swift-sdk/Package.swift +++ b/packages/swift-sdk/Package.swift @@ -32,7 +32,10 @@ let package = Package( .testTarget( name: "SwiftDashSDKTests", dependencies: ["SwiftDashSDK"], - path: "SwiftTests/SwiftDashSDKTests" + path: "SwiftTests/SwiftDashSDKTests", + // Persistent stores written by the builds that shipped each + // released schema version; `DashModelMigrationTests` opens them. + resources: [.copy("Fixtures")] ), // Integration tests against a local dashmate devnet. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 0126c3d65be..b29ff10ef51 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -3,16 +3,95 @@ import SwiftData /// Factory for creating SwiftData model containers for Dash Platform persistence public enum DashModelContainer { - /// Every registered schema version's model list, parameterised on the - /// one model whose shape differs between versions. + /// The wallet-and-platform model graph as every released schema version + /// registered it, built from the frozen copies under `FrozenSchemas/` + /// and parameterised on the one slot whose frozen shape differs between + /// versions (`PersistentAssetLock`, which V3 changed). + /// + /// Every entry is a nested frozen type, never a live one. A released + /// version's checksum is the hash of every entity it declares — and a + /// relationship binds its destination by entity NAME, so a version that + /// mixed one live model into an otherwise frozen graph would have that + /// live model's current shape hashed into it (the entity name resolves + /// to whichever Swift type claimed it first in the process). Freezing + /// the whole relationship-connected graph per version is what keeps a + /// released checksum stable no matter what the live models do next, and + /// `DashModelMigrationTests` proves it against stores an older build + /// actually wrote. /// /// Ordering is load-bearing only in the sense that it must not need to - /// change: keeping `assetLock` in the slot the live `PersistentAssetLock` - /// occupied means a frozen version's list is positionally identical to - /// what that version shipped. - private static func allModelTypes( + /// change: keeping each model in the slot its live counterpart occupies + /// makes a frozen version's list positionally identical to what that + /// version shipped. + private static func frozenModelGraph( assetLock: any PersistentModel.Type ) -> [any PersistentModel.Type] { + [ + DashSchemaV1.PersistentIdentity.self, + DashSchemaV1.PersistentDPNSName.self, + DashSchemaV1.PersistentDashpayProfile.self, + DashSchemaV1.PersistentDashpayContactProfile.self, + DashSchemaV1.PersistentDashpayContactRequest.self, + DashSchemaV1.PersistentDashpayPayment.self, + DashSchemaV1.PersistentDashpayIgnoredSender.self, + DashSchemaV1.PersistentDocument.self, + DashSchemaV1.PersistentDataContract.self, + DashSchemaV1.PersistentPublicKey.self, + DashSchemaV1.PersistentTokenBalance.self, + DashSchemaV1.PersistentKeyword.self, + DashSchemaV1.PersistentToken.self, + DashSchemaV1.PersistentDocumentType.self, + DashSchemaV1.PersistentIndex.self, + DashSchemaV1.PersistentProperty.self, + DashSchemaV1.PersistentTokenHistoryEvent.self, + DashSchemaV1.PersistentPlatformAddress.self, + DashSchemaV1.PersistentPlatformAddressesSyncState.self, + DashSchemaV1.PersistentWallet.self, + DashSchemaV1.PersistentAccount.self, + DashSchemaV1.PersistentCoreAddress.self, + DashSchemaV1.PersistentTransaction.self, + DashSchemaV1.PersistentTxo.self, + DashSchemaV1.PersistentPendingInput.self, + DashSchemaV1.PersistentWalletManagerMetadata.self, + DashSchemaV1.PersistentShieldedNote.self, + DashSchemaV1.PersistentShieldedOutgoingNote.self, + DashSchemaV1.PersistentShieldedSyncState.self, + DashSchemaV1.PersistentShieldedActivity.self, + DashSchemaV1.PersistentShieldedViewingKey.self, + assetLock, + DashSchemaV1.PersistentInvitation.self, + DashSchemaV1.PersistentMasternode.self + ] + } + + /// The exact model set registered as schema V1. Keep frozen: staged + /// migration identifies an existing store by this schema's checksum. + fileprivate static var v1ModelTypes: [any PersistentModel.Type] { + frozenModelGraph(assetLock: DashSchemaV1.PersistentAssetLock.self) + } + + /// The exact model set registered as schema V2 — V1 plus + /// `PersistentTrackedMasternode`. Frozen for the same reason as + /// `v1ModelTypes`. + fileprivate static var v2ModelTypes: [any PersistentModel.Type] { + v1ModelTypes + [DashSchemaV2.PersistentTrackedMasternode.self] + } + + /// The exact model set registered as schema V3 — V2 with the asset-lock + /// shape that gained `recipientIsExternal`. Frozen for the same reason + /// as `v1ModelTypes`. + fileprivate static var v3ModelTypes: [any PersistentModel.Type] { + frozenModelGraph(assetLock: DashSchemaV3.PersistentAssetLock.self) + + [DashSchemaV2.PersistentTrackedMasternode.self] + } + + /// All persistent model types in the current Dash SDK schema (V4). + /// Unlike the released versions above this list tracks the LIVE models, + /// so it moves whenever a model gains a property — which is exactly why + /// the released versions must not. When the next property lands: freeze + /// every model here into the version being retired + /// (`scripts/freeze_schema_models.py`), add a version, add a stage. + public static var modelTypes: [any PersistentModel.Type] { [ PersistentIdentity.self, PersistentDPNSName.self, @@ -45,38 +124,16 @@ public enum DashModelContainer { PersistentShieldedSyncState.self, PersistentShieldedActivity.self, PersistentShieldedViewingKey.self, - assetLock, + PersistentAssetLock.self, PersistentInvitation.self, - PersistentMasternode.self + PersistentMasternode.self, + PersistentTrackedMasternode.self ] } - /// The exact model set registered as schema V1. Keep frozen: staged - /// migration identifies an existing store by this schema's checksum, so - /// this list may only reference models whose shape is frozen (see - /// `DashSchemaFrozenModels.swift`). - fileprivate static var v1ModelTypes: [any PersistentModel.Type] { - allModelTypes(assetLock: DashSchemaV1.PersistentAssetLock.self) - } - - /// The exact model set registered as schema V2 — V1 plus - /// `PersistentTrackedMasternode`. Frozen for the same reason as - /// `v1ModelTypes`. - fileprivate static var v2ModelTypes: [any PersistentModel.Type] { - v1ModelTypes + [PersistentTrackedMasternode.self] - } - - /// All persistent model types in the current Dash SDK schema (V3). - /// Unlike `v1ModelTypes` / `v2ModelTypes` this list tracks the LIVE - /// models, so it moves whenever a model gains a property — which is - /// exactly why the released versions above must not. - public static var modelTypes: [any PersistentModel.Type] { - allModelTypes(assetLock: PersistentAssetLock.self) + [PersistentTrackedMasternode.self] - } - /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV4.self) } /// Create a persistent model container for storing data @@ -95,13 +152,37 @@ public enum DashModelContainer { groupContainer: groupContainer, cloudKitDatabase: cloudKit ? .automatic : .none ) + return try makeContainer(configuration: modelConfiguration) + } + /// Open (or create) the store at an explicit file URL through the same + /// schema and migration plan as `create(cloudKit:groupContainer:)`. The + /// migration tests use it to open stores written by older builds exactly + /// the way the app would. + static func create(url: URL) throws -> ModelContainer { + let modelConfiguration = ModelConfiguration( + schema: schema, + url: url, + allowsSave: true, + cloudKitDatabase: .none + ) + return try makeContainer(configuration: modelConfiguration) + } + + /// The one place a persistent container is built: the live schema is + /// constructed first (`schema`), and the container then runs the + /// migration plan over it. That order is what the frozen versions are + /// tested against, because it is the order under which a mixed + /// live/frozen graph used to rebind a released version's entities. + private static func makeContainer( + configuration: ModelConfiguration + ) throws -> ModelContainer { // Always wire the migration plan so stores created by an older SDK // advance through the registered versioned schemas. - return try ModelContainer( + try ModelContainer( for: schema, migrationPlan: DashMigrationPlan.self, - configurations: [modelConfiguration] + configurations: [configuration] ) } @@ -124,13 +205,14 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self] + [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self), - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self) + .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) ] } } @@ -251,6 +333,24 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// migrate with a nil `documentIdBase58`, which is the documented /// "no marketplace state tracked" signal — the next marketplace /// sync pass fills them in. +/// - `PersistentTxo` gained the optional `supersededByTxid`, and +/// `PersistentPendingInput` gained `isSweptTombstone` (defaulted +/// `false`). Together they let a sweep's claim on an input whose +/// funding TXO hasn't arrived yet survive the loser transaction's +/// deletion — previously that claim lived only on the doomed row's +/// `PersistentPendingInput`, which cascades away with it. Both +/// additive with defaults ⇒ lightweight migration; existing rows +/// migrate as ordinary (non-tombstone, non-superseded) entries. +/// - `PersistentPendingInput` gained the optional `winnerMinedHeight` +/// (a block-context sweep tombstone's finality stamp — the winner's +/// own mined height) and `PersistentWallet` gained the optional +/// `lastAppliedChainLockHeight` (the numeric chainlock watermark +/// delivered by `on_persist_wallet_changeset_chain_lock_height_fn`, +/// stored monotonic-max). Together they drive the bounded tombstone +/// lifetime: a tombstone is collected exactly when +/// `min(chainlockHeight, syncedHeight)` reaches its stamp. Both +/// optional ⇒ lightweight migration; pre-existing rows read as +/// unstamped (held forever) over a wallet with no boundary yet. /// Each of those is a destructive change to a unique-attribute /// column or to relationship topology, so any pre-existing dev /// store will fail to open and get rebuilt from scratch on next @@ -284,18 +384,36 @@ public enum DashSchemaV2: VersionedSchema { /// optional column on an existing entity, so a lightweight migration /// preserves every existing row and backfills `NULL`. /// -/// This is the first version to be registered alongside a genuinely frozen -/// copy of the model it changes (`DashSchemaV1.PersistentAssetLock`). Without -/// that copy, adding the property would have mutated V1's and V2's checksums -/// in place and a store written by the V2 binary would have matched no -/// registered schema, failing to open with Cocoa error 134504 rather than -/// migrating. Follow the same pattern for the next property added to any -/// model: freeze the old shape, add a version, add a stage. +/// V1 and V2 reference the asset lock as it was before this property +/// (`DashSchemaV1.PersistentAssetLock`); V3 references its own frozen copy +/// (`DashSchemaV3.PersistentAssetLock`), not the live model, so the next +/// change to the live asset lock cannot move V3's checksum either. public enum DashSchemaV3: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) } + public static var models: [any PersistentModel.Type] { + DashModelContainer.v3ModelTypes + } +} + +/// Version 4 adds the durable sweep fields to the wallet transaction graph +/// (`PersistentWallet.lastAppliedChainLockHeight`, +/// `PersistentTransaction.isGloballySwept`, `PersistentTxo.supersededByTxid`, +/// `PersistentPendingInput.isSweptTombstone` / `winnerMinedHeight`). The new +/// columns are optional or defaulted, so existing rows migrate without a +/// custom transform. +/// +/// This is the live version: the only one whose model list names the +/// top-level `@Model` types. V1 through V3 are built entirely from the +/// frozen copies under `FrozenSchemas/`, so `DashModelMigrationTests` can +/// open stores written by the builds that shipped them. +public enum DashSchemaV4: VersionedSchema { + public static var versionIdentifier: Schema.Version { + Schema.Version(4, 0, 0) + } + public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift index 72df796e36c..65cc556f7dc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift @@ -14,32 +14,44 @@ import SwiftData // Cocoa error 134504 ("Cannot use staged migration with an unknown model // version") instead of migrating it. // -// This file holds the frozen copies. A frozen copy must be a *nested* type, -// because SwiftData derives the entity name from the unqualified type name — -// `DashSchemaV1.PersistentAssetLock` and the top-level `PersistentAssetLock` -// are two distinct Swift types that both describe the entity named -// "PersistentAssetLock", which is exactly what lets a migration stage map one -// onto the other. (`DashModelMigrationTests` asserts that entity naming, so a -// future SwiftData change to the derivation would fail loudly rather than -// silently renaming an entity.) +// A frozen copy must be a *nested* type, because SwiftData derives the entity +// name from the unqualified type name — `DashSchemaV1.PersistentAssetLock` +// and the top-level `PersistentAssetLock` are two distinct Swift types that +// both describe the entity named "PersistentAssetLock", which is exactly +// what lets a migration stage map one onto the other. // // ## Scope of the freeze // -// Only `PersistentAssetLock` is frozen today, because it is the only model -// this file's callers have changed since V2 shipped. Every other model in -// `DashSchemaV1` / `DashSchemaV2` is still referenced live and therefore -// still carries the same latent defect. Freezing them is a mechanical but -// wide change (34 models) and is deliberately left out of the change that -// introduced this file; when the next model gains a property, freeze that -// one here too and add the matching stage. +// Every model a released version registers is frozen — the whole graph, not +// only the models whose shape has changed since. SwiftData resolves a +// relationship's destination by entity name, binding that name to the first +// Swift type that claims it in the process, so a frozen `PersistentWallet` +// referenced from a live `PersistentAccount.wallet` would be hashed with +// the LIVE wallet's shape whenever the live schema was built first (which +// `DashModelContainer.create` always does). Freezing only the changed +// models therefore cannot keep a released checksum stable; freezing the +// graph can, and `DashModelMigrationTests` proves it against stores the +// older builds actually wrote. // -// V1's own checksum has already drifted from what actually shipped as V1 -// (see the `DashSchemaV1` doc comment: several models were changed in place -// while V1 was the only registered version, and dev stores at V1 are -// knowingly expected to fail open and be rebuilt). The frozen copy below is -// therefore the shape as of the V2 release, shared by V1 and V2 — which is -// what makes V1 -> V2 continue to be "add `PersistentTrackedMasternode`" -// and nothing else, exactly as before. +// The frozen graph lives under `FrozenSchemas/`, one file per model, +// generated from the live models at the commit that shipped the version by +// `scripts/freeze_schema_models.py` (rerun it against the same commit to +// verify a file). V1, V2 and V3 share one copy of every model whose shape +// did not change between them; V2 adds `PersistentTrackedMasternode` and V3 +// swaps the asset lock for the shape that gained `recipientIsExternal`. +// The value types a model stores inline are part of the frozen surface +// too: SwiftData expands a stored Codable struct into composite attributes +// of the owning entity (`PersistentToken`'s `ChangeControlRules` columns, +// for one), so `FrozenSchemas/DashSchemaV1+TokenTypes.swift` carries the +// nested copies the frozen models resolve to, and the live +// `Persistence/Types/TokenTypes.swift` may change freely. +// +// The one hand-written copy below is the asset lock as V1 and V2 shipped +// it: that shape predates every commit the generator could be pointed at, +// so it cannot be regenerated. V1's own checksum has already drifted from +// what actually shipped as V1 (see the `DashSchemaV1` doc comment: several +// models were changed in place while V1 was the only registered version, +// and dev stores at V1 are knowingly expected to fail open and be rebuilt). extension DashSchemaV1 { /// `PersistentAssetLock` frozen at the shape it had when schema V2 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentAccount.swift new file mode 100644 index 00000000000..fbfef89be85 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentAccount.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentAccount` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentAccount { + #Unique([ + \.wallet, + \.accountType, + \.accountIndex, + \.standardTag, + \.registrationIndex, + \.keyClass, + \.userIdentityId, + \.friendIdentityId, + ]) + + var accountType: UInt32 + var accountIndex: UInt32 + var accountTypeName: String + var balanceConfirmed: UInt64 + var balanceUnconfirmed: UInt64 + var externalHighestUsed: Int32 + var internalHighestUsed: Int32 + var standardTag: UInt8 + var registrationIndex: UInt32 + var keyClass: UInt32 + var userIdentityId: Data + var friendIdentityId: Data + @Attribute(.unique) var accountExtendedPubKeyBytes: Data? + var createdAt: Date + var lastUpdated: Date + + var wallet: PersistentWallet + + @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) + var coreAddresses: [PersistentCoreAddress] + + @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) + var platformAddresses: [PersistentPlatformAddress] + + var involvedTransactions: [PersistentTransaction] = [] + + init( + wallet: PersistentWallet, + accountType: UInt32, + accountIndex: UInt32, + accountTypeName: String + ) { + self.wallet = wallet + self.accountType = accountType + self.accountIndex = accountIndex + self.accountTypeName = accountTypeName + self.balanceConfirmed = 0 + self.balanceUnconfirmed = 0 + self.externalHighestUsed = -1 + self.internalHighestUsed = -1 + self.standardTag = 0 + self.registrationIndex = 0 + self.keyClass = 0 + self.userIdentityId = Data() + self.friendIdentityId = Data() + self.accountExtendedPubKeyBytes = nil + self.createdAt = Date() + self.lastUpdated = Date() + self.coreAddresses = [] + self.platformAddresses = [] + self.involvedTransactions = [] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentCoreAddress.swift new file mode 100644 index 00000000000..45dd2a75823 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentCoreAddress.swift @@ -0,0 +1,68 @@ +import Foundation +import SwiftData + +// `PersistentCoreAddress` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentCoreAddress { + @Attribute(.unique) var address: String + var publicKey: Data + var keyType: UInt8 = 0 + var poolTypeTag: UInt8 + var addressIndex: UInt32 + var derivationPath: String + var isUsed: Bool + var firstSeenHeight: UInt32 + var lastSeenHeight: UInt32 + var balance: UInt64 + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.coreAddress) + var txos: [PersistentTxo] = [] + + init( + address: String, + publicKey: Data = Data(), + keyType: UInt8 = 0, + poolTypeTag: UInt8, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0 + ) { + self.address = address + self.publicKey = publicKey + self.keyType = keyType + self.poolTypeTag = poolTypeTag + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.balance = balance + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV1.PersistentCoreAddress { + var poolTypeName: String { + switch poolTypeTag { + case 0: return "External" + case 1: return "Internal" + case 2: return "Additional" + case 3: return "Additional (Hardened)" + default: return "Unknown(\(poolTypeTag))" + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDPNSName.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDPNSName.swift new file mode 100644 index 00000000000..642068baa87 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDPNSName.swift @@ -0,0 +1,132 @@ +import Foundation +import SwiftData + +// `PersistentDPNSName` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentDPNSName { + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var label: String + + var normalizedLabel: String + + var parentDomainName: String + + var normalizedParentDomainName: String + + var acquiredAt: UInt64 + + var isOwned: Bool = true + + var documentIdBase58: String? + + var priceCredits: Int64? + + var saleStatusRaw: Int16 = 0 + + var counterpartyIdBase58: String? + + var documentCreatedAtMs: UInt64? + + var documentUpdatedAtMs: UInt64? + + var documentTransferredAtMs: UInt64? + + var marketplaceUpdatedAt: UInt64 = 0 + + var identity: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + identity: PersistentIdentity, + label: String, + parentDomainName: String = "dash", + acquiredAt: UInt64 = 0, + isOwned: Bool = true + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.label = label + self.normalizedLabel = Self.normalize(label) + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = Self.normalize(parentDomainName) + self.acquiredAt = acquiredAt + self.isOwned = isOwned + self.documentIdBase58 = nil + self.priceCredits = nil + self.saleStatusRaw = 0 + self.counterpartyIdBase58 = nil + self.documentCreatedAtMs = nil + self.documentUpdatedAtMs = nil + self.documentTransferredAtMs = nil + self.marketplaceUpdatedAt = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV1.PersistentDPNSName { + var saleStatus: DpnsNameSaleStatus? { + guard documentIdBase58 != nil else { return nil } + switch saleStatusRaw { + case 0: + return .owned + case 1: + guard let to = counterpartyId else { return nil } + return .sold(to: to) + case 2: + guard let to = counterpartyId else { return nil } + return .transferred(to: to) + default: + return nil + } + } + + var counterpartyId: Data? { + counterpartyIdBase58.flatMap { Data.identifier(fromBase58: $0) } + } + + var listedPriceCredits: UInt64? { + guard documentIdBase58 != nil, let priceCredits else { return nil } + return UInt64(bitPattern: priceCredits) + } +} + +extension DashSchemaV1.PersistentDPNSName { + static func normalize(_ input: String) -> String { + String(input.map { c -> Character in + switch c { + case "o", "O": return "0" + case "i", "I": return "1" + case "l", "L": return "1" + default: return Character(c.lowercased()) + } + }) + } +} + +extension DashSchemaV1.PersistentDPNSName { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { name in + name.identity.identityId == target && name.isOwned == true + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactProfile.swift new file mode 100644 index 00000000000..151eb947886 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactProfile.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactProfile` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentDashpayContactProfile { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var contactIdentityId: Data + + var displayName: String? + + var publicMessage: String? + + var bio: String? + + var avatarUrl: String? + + var avatarHash: Data? + + var avatarFingerprint: Data? + + var checkedAtMs: UInt64 + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + checkedAtMs: UInt64, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.checkedAtMs = checkedAtMs + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV1.PersistentDashpayContactProfile { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + contactIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let contact = contactIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.contactIdentityId == contact + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactRequest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactRequest.swift new file mode 100644 index 00000000000..d8dd1c644ff --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactRequest.swift @@ -0,0 +1,118 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactRequest` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentDashpayContactRequest { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var contactIdentityId: Data + + var isOutgoing: Bool + + var senderKeyIndex: UInt32 + + var recipientKeyIndex: UInt32 + + var accountReference: UInt32 + + var encryptedPublicKey: Data + + var encryptedAccountLabel: Data? + + var autoAcceptProof: Data? + + var coreHeightCreatedAt: UInt32 + + var createdAtMillis: UInt64 + + var paymentChannelBroken: Bool = false + + var contactAlias: String? + + var contactNote: String? + + var contactHidden: Bool = false + + var contactAccountLabel: String? + + var contactAcceptedAccounts: [UInt32] = [] + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + isOutgoing: Bool, + senderKeyIndex: UInt32, + recipientKeyIndex: UInt32, + accountReference: UInt32, + encryptedPublicKey: Data, + encryptedAccountLabel: Data? = nil, + autoAcceptProof: Data? = nil, + coreHeightCreatedAt: UInt32, + createdAtMillis: UInt64, + paymentChannelBroken: Bool = false + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.isOutgoing = isOutgoing + self.senderKeyIndex = senderKeyIndex + self.recipientKeyIndex = recipientKeyIndex + self.accountReference = accountReference + self.encryptedPublicKey = encryptedPublicKey + self.encryptedAccountLabel = encryptedAccountLabel + self.autoAcceptProof = autoAcceptProof + self.coreHeightCreatedAt = coreHeightCreatedAt + self.createdAtMillis = createdAtMillis + self.paymentChannelBroken = paymentChannelBroken + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV1.PersistentDashpayContactRequest { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + isOutgoing: Bool + ) -> Predicate { + let target = ownerIdentityId + let direction = isOutgoing + return #Predicate { row in + row.ownerIdentityId == target && row.isOutgoing == direction + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayIgnoredSender.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayIgnoredSender.swift new file mode 100644 index 00000000000..8b4bc10060d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayIgnoredSender.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftData + +// `PersistentDashpayIgnoredSender` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentDashpayIgnoredSender { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var ignoredSenderId: Data + + var owner: PersistentIdentity + + var ignoredAt: Date + + init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } + } +} + +extension DashSchemaV1.PersistentDashpayIgnoredSender { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + ignoredSenderId: Data + ) -> Predicate { + let target = ownerIdentityId + let sender = ignoredSenderId + return #Predicate { row in + row.ownerIdentityId == target + && row.ignoredSenderId == sender + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayPayment.swift new file mode 100644 index 00000000000..266faba2e25 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayPayment.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData + +// `PersistentDashpayPayment` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentDashpayPayment { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var counterpartyIdentityId: Data + + var amountDuffs: UInt64 + + var directionRaw: UInt8 + + var direction: DashPayPaymentDirection { + get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } + set { directionRaw = newValue.rawValue } + } + + var statusRaw: UInt8 + + var status: DashPayPaymentStatus { + get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + var txid: String + + var memo: String? + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + counterpartyIdentityId: Data, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.counterpartyIdentityId = counterpartyIdentityId + self.amountDuffs = amountDuffs + self.directionRaw = direction.rawValue + self.statusRaw = status.rawValue + self.txid = txid + self.memo = memo + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV1.PersistentDashpayPayment { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + counterpartyIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let counterparty = counterpartyIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.counterpartyIdentityId == counterparty + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayProfile.swift new file mode 100644 index 00000000000..04bc64fdbfe --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayProfile.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftData + +// `PersistentDashpayProfile` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentDashpayProfile { + #Unique([\.networkRaw, \.identity]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var displayName: String? + + var publicMessage: String? + + var bio: String? + + var avatarUrl: String? + + var avatarHash: Data? + + var avatarFingerprint: Data? + + var identity: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + identity: PersistentIdentity, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV1.PersistentDashpayProfile { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { profile in + profile.identity.identityId == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDataContract.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDataContract.swift new file mode 100644 index 00000000000..dcd8c4b5aab --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDataContract.swift @@ -0,0 +1,286 @@ +import Foundation +import SwiftData + +// `PersistentDataContract` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentDataContract { + #Index([\.networkRaw]) + + @Attribute(.unique) var id: Data + var name: String + var serializedContract: Data + var createdAt: Date + var lastAccessedAt: Date + + var binarySerialization: Data? + + var version: Int? + var ownerId: Data? + + @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) + var keywordRelations: [PersistentKeyword] + var contractDescription: String? + + var schemaData: Data + var documentTypesData: Data + + var groupsData: Data? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var lastUpdated: Date + var lastSyncedAt: Date? + + var canBeDeleted: Bool + var readonly: Bool + var keepsHistory: Bool + var schemaDefs: Int? + + var documentsKeepHistoryContractDefault: Bool + var documentsMutableContractDefault: Bool + var documentsCanBeDeletedContractDefault: Bool + + @Relationship(deleteRule: .cascade, inverse: \PersistentToken.dataContract) + var tokens: [PersistentToken]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocumentType.dataContract) + var documentTypes: [PersistentDocumentType]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.dataContract) + var documents: [PersistentDocument] + + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) + var ownerIdentity: PersistentIdentity? + + var hasTokens: Bool + var tokensData: Data? + + var idBase58: String { + id.toBase58String() + } + + var ownerIdBase58: String? { + ownerId?.toBase58String() + } + + var parsedContract: [String: Any]? { + try? JSONSerialization.jsonObject(with: serializedContract, options: []) as? [String: Any] + } + + var binarySerializationHex: String? { + binarySerialization?.toHexString() + } + + var keywords: [String] { + keywordRelations.map { $0.keyword } + } + + var schema: [String: Any] { + get { + guard let json = try? JSONSerialization.jsonObject(with: schemaData), + let dict = json as? [String: Any] else { + return [:] + } + return dict + } + set { + schemaData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var documentTypesList: [String] { + get { + guard let json = try? JSONSerialization.jsonObject(with: documentTypesData), + let array = json as? [String] else { + return [] + } + return array + } + set { + documentTypesData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var tokenConfigurations: [String: Any]? { + get { + guard let data = tokensData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + tokensData = try? JSONSerialization.data(withJSONObject: newValue) + hasTokens = true + } else { + tokensData = nil + hasTokens = false + } + lastUpdated = Date() + } + } + + var groups: [String: Any]? { + get { + guard let data = groupsData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + groupsData = try? JSONSerialization.data(withJSONObject: newValue) + } else { + groupsData = nil + } + lastUpdated = Date() + } + } + + init( + id: Data, + name: String, + serializedContract: Data, + version: Int? = 1, + ownerId: Data? = nil, + schema: [String: Any] = [:], + documentTypesList: [String] = [], + keywords: [String] = [], + description: String? = nil, + hasTokens: Bool = false, + network: Network + ) { + self.id = id + self.name = name + self.serializedContract = serializedContract + self.createdAt = Date() + self.lastAccessedAt = Date() + self.version = version + self.ownerId = ownerId + + self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() + self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() + + self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } + self.contractDescription = description + + self.hasTokens = hasTokens + self.tokensData = nil + + self.groupsData = nil + + self.documents = [] + + self.ownerIdentity = nil + + self.networkRaw = network.rawValue + self.lastUpdated = Date() + self.lastSyncedAt = nil + + self.canBeDeleted = false + self.readonly = false + self.keepsHistory = false + self.documentsKeepHistoryContractDefault = false + self.documentsMutableContractDefault = true + self.documentsCanBeDeletedContractDefault = true + } + + func updateLastAccessed() { + self.lastAccessedAt = Date() + } + + func updateVersion(_ newVersion: Int) { + self.version = newVersion + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func addDocument(_ document: PersistentDocument) { + documents.append(document) + lastUpdated = Date() + } + + func removeDocument(withId documentId: String) { + if let docIdData = Data.identifier(fromBase58: documentId) { + documents.removeAll { $0.id == docIdData } + } + lastUpdated = Date() + } + } +} + +extension DashSchemaV1.PersistentDataContract { + static func predicate(contractId: String) -> Predicate { + guard let idData = Data.identifier(fromBase58: contractId) else { + return #Predicate { _ in false } + } + return #Predicate { contract in + contract.id == idData + } + } + + static func predicate(ownerId: Data) -> Predicate { + #Predicate { contract in + contract.ownerId == ownerId + } + } + + static func predicate(name: String) -> Predicate { + #Predicate { contract in + contract.name.localizedStandardContains(name) + } + } + + static var contractsWithTokensPredicate: Predicate { + #Predicate { contract in + contract.hasTokens == true + } + } + + static func predicate(keyword: String) -> Predicate { + #Predicate { contract in + contract.keywordRelations.contains { $0.keyword == keyword } + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { contract in + contract.lastSyncedAt == nil || contract.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.networkRaw == target + } + } + + static func contractsWithTokensPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.hasTokens == true && contract.networkRaw == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocument.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocument.swift new file mode 100644 index 00000000000..84ca9b0a4d7 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocument.swift @@ -0,0 +1,181 @@ +import Foundation +import SwiftData + +// `PersistentDocument` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentDocument { + #Index([\.networkRaw]) + + @Attribute(.unique) var documentId: String + + var documentType: String + var revision: Int32 + var data: Data + + var contractId: String + var ownerId: String + + var contractIdData: Data + var ownerIdData: Data + + var createdAt: Date + var updatedAt: Date + var transferredAt: Date? + + var createdAtBlockHeight: Int64? + var updatedAtBlockHeight: Int64? + var transferredAtBlockHeight: Int64? + + var createdAtCoreBlockHeight: Int64? + var updatedAtCoreBlockHeight: Int64? + var transferredAtCoreBlockHeight: Int64? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var isDeleted: Bool = false + + var localCreatedAt: Date + var localUpdatedAt: Date + + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + var ownerIdentity: PersistentIdentity? + + var id: Data { + Data.identifier(fromBase58: documentId) ?? Data() + } + + var idBase58: String { + documentId + } + + var ownerIdBase58: String { + ownerId + } + + var contractIdBase58: String { + contractId + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var displayTitle: String { + guard let props = properties else { return "Document" } + + if let title = props["title"] as? String { return title } + if let name = props["name"] as? String { return name } + if let label = props["label"] as? String { return label } + if let normalizedLabel = props["normalizedLabel"] as? String { return normalizedLabel } + + return documentType + } + + var summary: String { + var parts: [String] = [] + + parts.append("Type: \(documentType)") + parts.append("Rev: \(revision)") + + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateStyle = .short + parts.append("Created: \(formatter.string(from: createdAt))") + + return parts.joined(separator: " • ") + } + + init( + documentId: String, + documentType: String, + revision: Int32, + data: Data, + contractId: String, + ownerId: String, + network: Network + ) { + self.documentId = documentId + self.documentType = documentType + self.revision = revision + self.data = data + self.contractId = contractId + self.ownerId = ownerId + self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() + self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() + self.networkRaw = network.rawValue + self.createdAt = Date() + self.updatedAt = Date() + self.localCreatedAt = Date() + self.localUpdatedAt = Date() + } + + func updateProperties(_ newData: Data) { + self.data = newData + self.updatedAt = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = Int32(newRevision) + self.updatedAt = Date() + } + + func markAsDeleted() { + self.isDeleted = true + self.updatedAt = Date() + } + + static func predicate(documentId: String) -> Predicate { + #Predicate { doc in + doc.documentId == documentId && doc.isDeleted == false + } + } + + static func predicate(contractId: String, network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { doc in + doc.contractId == contractId && doc.networkRaw == target && doc.isDeleted == false + } + } + + static func predicate(ownerId: Data) -> Predicate { + let ownerIdString = ownerId.toBase58String() + return #Predicate { doc in + doc.ownerId == ownerIdString && doc.isDeleted == false + } + } + + func linkToLocalIdentityIfNeeded(in modelContext: ModelContext) { + guard ownerIdentity == nil else { return } + + let ownerIdToMatch = self.ownerIdData + let identityPredicate = #Predicate { identity in + identity.identityId == ownerIdToMatch && identity.isLocal == true + } + + let descriptor = FetchDescriptor(predicate: identityPredicate) + + do { + if let localIdentity = try modelContext.fetch(descriptor).first { + self.ownerIdentity = localIdentity + self.localUpdatedAt = Date() + } + } catch { + print("Failed to link document to local identity: \(error)") + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocumentType.swift new file mode 100644 index 00000000000..85b85a1414f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocumentType.swift @@ -0,0 +1,101 @@ +import Foundation +import SwiftData + +// `PersistentDocumentType` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentDocumentType { + @Attribute(.unique) var id: Data + var contractId: Data + var name: String + + var schemaJSON: Data + var propertiesJSON: Data + + var documentsKeepHistory: Bool + var documentsMutable: Bool + var documentsCanBeDeleted: Bool + var documentsTransferable: Bool + + var indexOnly: Bool = false + + var requiredFieldsJSON: Data? + + var securityLevel: Int + + var tradeMode: Int + var creationRestrictionMode: Int + + var requiresIdentityEncryptionBoundedKey: Bool + var requiresIdentityDecryptionBoundedKey: Bool + + var createdAt: Date + var lastAccessedAt: Date + + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) + var documents: [PersistentDocument]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) + var indices: [PersistentIndex]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) + var propertiesList: [PersistentProperty]? + + init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { + var idData = contractId + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.name = name + self.schemaJSON = schemaJSON + self.propertiesJSON = propertiesJSON + self.documentsKeepHistory = false + self.documentsMutable = true + self.documentsCanBeDeleted = true + self.documentsTransferable = false + self.securityLevel = 0 + self.tradeMode = 0 + self.creationRestrictionMode = 0 + self.requiresIdentityEncryptionBoundedKey = false + self.requiresIdentityDecryptionBoundedKey = false + self.createdAt = Date() + self.lastAccessedAt = Date() + } + } +} + +extension DashSchemaV1.PersistentDocumentType { + var contractIdBase58: String { + contractId.toBase58String() + } + + var schema: [String: Any]? { + try? JSONSerialization.jsonObject(with: schemaJSON, options: []) as? [String: Any] + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String: Any] + } + + var persistentProperties: [DashSchemaV1.PersistentProperty]? { + return propertiesList + } + + var requiredFields: [String]? { + guard let data = requiredFieldsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String] + } + + var documentCount: Int { + documents?.count ?? 0 + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIdentity.swift new file mode 100644 index 00000000000..353f5c84d3a --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIdentity.swift @@ -0,0 +1,274 @@ +import Foundation +import SwiftData + +// `PersistentIdentity` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentIdentity { + #Index([\.networkRaw]) + + @Attribute(.unique) var identityId: Data + var balance: Int64 + var revision: Int64 + var isLocal: Bool + var alias: String? + var dpnsName: String? + var mainDpnsName: String? + var identityType: String + + var votingPrivateKeyIdentifier: String? + var ownerPrivateKeyIdentifier: String? + var payoutPrivateKeyIdentifier: String? + + @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] + + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var wallet: PersistentWallet? + var identityIndex: UInt32 = 0 + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] + @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) + var dpnsNames: [PersistentDPNSName] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) + var dashpayProfile: PersistentDashpayProfile? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) + var contactRequests: [PersistentDashpayContactRequest] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + var dashpayPayments: [PersistentDashpayPayment] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + var contactProfiles: [PersistentDashpayContactProfile] = [] + + var ownedDataContracts: [PersistentDataContract] + + init( + identityId: Data, + balance: Int64 = 0, + revision: Int64 = 0, + isLocal: Bool = true, + alias: String? = nil, + dpnsName: String? = nil, + mainDpnsName: String? = nil, + identityType: IdentityType = .user, + votingPrivateKeyIdentifier: String? = nil, + ownerPrivateKeyIdentifier: String? = nil, + payoutPrivateKeyIdentifier: String? = nil, + network: Network, + identityIndex: UInt32 = 0 + ) { + self.identityId = identityId + self.balance = balance + self.revision = revision + self.isLocal = isLocal + self.alias = alias + self.dpnsName = dpnsName + self.mainDpnsName = mainDpnsName + self.identityType = identityType.rawValue + self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier + self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier + self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier + self.networkRaw = network.rawValue + self.identityIndex = identityIndex + self.publicKeys = [] + self.documents = [] + self.tokenBalances = [] + self.dpnsNames = [] + self.dashpayProfile = nil + self.contactRequests = [] + self.dashpayPayments = [] + self.dashpayIgnoredSenders = [] + self.contactProfiles = [] + self.ownedDataContracts = [] + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + } + + var identityIdString: String { + identityId.toHexString() + } + + var identityIdBase58: String { + identityId.toBase58String() + } + + var formattedBalance: String { + let dashAmount = Double(balance) / 100_000_000_000 + return String(format: "%.8f DASH", dashAmount) + } + + var identityPublicKeys: [IdentityPublicKey] { + publicKeys.compactMap { $0.toIdentityPublicKey() } + } + + var displayName: String { + if let alias = alias, !alias.isEmpty { + return alias + } + if let mainDpnsName = mainDpnsName, !mainDpnsName.isEmpty { + return mainDpnsName + } + if let dpnsName = dpnsName, !dpnsName.isEmpty { + return dpnsName + } + return String(identityIdString.prefix(12)) + "..." + } + + var identityTypeEnum: IdentityType { + IdentityType(rawValue: identityType) ?? .user + } + + func updateBalance(_ newBalance: Int64) { + self.balance = newBalance + self.lastUpdated = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = newRevision + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func updateDPNSName(_ name: String?) { + self.dpnsName = name + self.lastUpdated = Date() + } + + func addPublicKey(_ key: PersistentPublicKey) { + publicKeys.append(key) + lastUpdated = Date() + } + + func removePublicKey(withId keyId: Int32) { + publicKeys.removeAll { $0.keyId == keyId } + lastUpdated = Date() + } + } +} + +extension DashSchemaV1.PersistentIdentity { + static func predicate(identityId: Data) -> Predicate { + #Predicate { identity in + identity.identityId == identityId + } + } + + static var walletOwnedIdentitiesPredicate: Predicate { + #Predicate { identity in + identity.wallet != nil + } + } + + static func predicate(type: IdentityType) -> Predicate { + let typeString = type.rawValue + return #Predicate { identity in + identity.identityType == typeString + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { identity in + identity.lastSyncedAt == nil || identity.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.networkRaw == target + } + } + + static func walletOwnedIdentitiesPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.wallet != nil && identity.networkRaw == target + } + } + + static func fetch( + in context: ModelContext, + identityId: Data + ) -> DashSchemaV1.PersistentIdentity? { + let target = identityId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == target } + ) + return try? context.fetch(descriptor).first + } +} + +extension DashSchemaV1.PersistentIdentity { + @discardableResult + static func updateBalance( + in context: ModelContext, + identityId: Data, + balance: UInt64 + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.balance = Int64(bitPattern: balance) + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateDpnsName( + in context: ModelContext, + identityId: Data, + dpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.dpnsName = dpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateMainDpnsName( + in context: ModelContext, + identityId: Data, + mainDpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.mainDpnsName = mainDpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func remove( + in context: ModelContext, + identityId: Data + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + context.delete(row) + return true + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIndex.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIndex.swift new file mode 100644 index 00000000000..4e5682cf689 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIndex.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftData + +// `PersistentIndex` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentIndex { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + var unique: Bool + var nullSearchable: Bool + var contested: Bool + + var countable: String? + var rangeCountable: Bool = false + var summable: String? + var rangeSummable: Bool = false + var averageable: String? + var rangeAverageable: Bool = false + + var rankedCountable: Bool = false + var rankedSummable: Bool = false + var rankedAverageable: Bool = false + + var terminal: String? + + var preallocated: Bool = false + + var timeRangeJSON: Data? + + var propertiesJSON: Data + + var contestedDetailsJSON: Data? + + var createdAt: Date + + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.unique = false + self.nullSearchable = false + self.contested = false + + if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { + self.propertiesJSON = jsonData + } else { + self.propertiesJSON = Data() + } + + self.createdAt = Date() + } + } +} + +extension DashSchemaV1.PersistentIndex { + var properties: [String]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String] + } + + var contestedDetails: [String: Any]? { + guard let data = contestedDetailsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var timeRange: [String: Any]? { + guard let data = timeRangeJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentInvitation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentInvitation.swift new file mode 100644 index 00000000000..2d263aaf8ce --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentInvitation.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftData + +// `PersistentInvitation` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentInvitation { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var rawOutPoint: Data + + var walletId: Data + + var fundingIndexRaw: Int + + var amountDuffs: Int64 + + var expiryUnix: Int + + var createdAtSecs: Int + + var hasInviter: Bool + + var statusRaw: Int + + var reclaimInFlight: Bool = false + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + rawOutPoint: Data, + walletId: Data, + fundingIndexRaw: Int, + amountDuffs: Int64, + expiryUnix: Int, + createdAtSecs: Int, + hasInviter: Bool, + statusRaw: Int, + reclaimInFlight: Bool = false + ) { + self.outPointHex = outPointHex + self.rawOutPoint = rawOutPoint + self.walletId = walletId + self.fundingIndexRaw = fundingIndexRaw + self.amountDuffs = amountDuffs + self.expiryUnix = expiryUnix + self.createdAtSecs = createdAtSecs + self.hasInviter = hasInviter + self.statusRaw = statusRaw + self.reclaimInFlight = reclaimInFlight + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaV1.PersistentInvitation { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentKeyword.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentKeyword.swift new file mode 100644 index 00000000000..11ee3eede06 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentKeyword.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +// `PersistentKeyword` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentKeyword { + @Attribute(.unique) var id: String + var keyword: String + var contractId: String + + var dataContract: PersistentDataContract? + + init(keyword: String, contractId: String) { + self.id = "\(contractId)_\(keyword)" + self.keyword = keyword + self.contractId = contractId + } + } +} + +extension DashSchemaV1.PersistentKeyword { + static func predicate(keyword: String) -> Predicate { + #Predicate { item in + item.keyword.localizedStandardContains(keyword) + } + } + + static func predicate(contractId: String) -> Predicate { + #Predicate { item in + item.contractId == contractId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentMasternode.swift new file mode 100644 index 00000000000..56295dcb336 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentMasternode.swift @@ -0,0 +1,185 @@ +import Foundation +import SwiftData + +// `PersistentMasternode` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentMasternode { + #Unique([\.walletId, \.proTxHash]) + + var walletId: Data + var proTxHash: Data + var registrationTxid: Data + + var serviceAddress: String? + var isEvonode: Bool + + var ownerKeyHash: Data? + var votingKeyHash: Data? + var ownerAddress: String? + var votingAddress: String? + var operatorPublicKey: Data? + var platformNodeId: Data? + var payoutAddress: String? + var operatorPseudoAddress: String? + var platformNodeAddress: String? + + var ownerInWallet: Bool = false + var ownerAccountType: UInt8 = 0 + var ownerKeyIndex: UInt32 = 0 + var votingInWallet: Bool = false + var votingAccountType: UInt8 = 0 + var votingKeyIndex: UInt32 = 0 + var operatorInWallet: Bool = false + var operatorAccountType: UInt8 = 0 + var operatorKeyIndex: UInt32 = 0 + var platformInWallet: Bool = false + var platformAccountType: UInt8 = 0 + var platformKeyIndex: UInt32 = 0 + + var collateralTxid: Data? + var collateralVout: UInt32 + + var revoked: Bool + var revocationReason: UInt16 + var statusRaw: UInt8 = 3 + + var registrationHeight: UInt32 + var hasRegistration: Bool + var txCount: UInt32 + + var orderIndex: UInt32 + var typeIndex: UInt32 = 0 + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + proTxHash: Data, + registrationTxid: Data, + serviceAddress: String? = nil, + isEvonode: Bool = false, + ownerKeyHash: Data? = nil, + votingKeyHash: Data? = nil, + ownerAddress: String? = nil, + votingAddress: String? = nil, + operatorPublicKey: Data? = nil, + platformNodeId: Data? = nil, + payoutAddress: String? = nil, + collateralTxid: Data? = nil, + collateralVout: UInt32 = 0, + revoked: Bool = false, + revocationReason: UInt16 = 0, + statusRaw: UInt8 = 3, + registrationHeight: UInt32 = 0, + hasRegistration: Bool = false, + txCount: UInt32 = 0, + orderIndex: UInt32 = 0, + typeIndex: UInt32 = 0 + ) { + self.walletId = walletId + self.proTxHash = proTxHash + self.registrationTxid = registrationTxid + self.serviceAddress = serviceAddress + self.isEvonode = isEvonode + self.ownerKeyHash = ownerKeyHash + self.votingKeyHash = votingKeyHash + self.ownerAddress = ownerAddress + self.votingAddress = votingAddress + self.operatorPublicKey = operatorPublicKey + self.platformNodeId = platformNodeId + self.payoutAddress = payoutAddress + self.collateralTxid = collateralTxid + self.collateralVout = collateralVout + self.revoked = revoked + self.revocationReason = revocationReason + self.statusRaw = statusRaw + self.registrationHeight = registrationHeight + self.hasRegistration = hasRegistration + self.txCount = txCount + self.orderIndex = orderIndex + self.typeIndex = typeIndex + self.createdAt = Date() + self.lastUpdated = Date() + } + + var proTxHashHex: String { + proTxHash.reversed().map { String(format: "%02x", $0) }.joined() + } + + var proTxHashShort: String { + let hex = proTxHashHex + guard hex.count >= 12 else { return hex } + return "\(String(hex.prefix(6)))…\(String(hex.suffix(6)))" + } + + var ownerKeyHashHex: String? { + ownerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var votingKeyHashHex: String? { + votingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + static func providerAccountTypeName(_ tag: UInt8) -> String { + switch tag { + case 8: return "ProviderVotingKeys" + case 9: return "ProviderOwnerKeys" + case 10: return "ProviderOperatorKeys" + case 11: return "ProviderPlatformKeys" + default: return "Unknown(\(tag))" + } + } + + static func keyOwnershipLabel( + inWallet: Bool, + accountType: UInt8, + index: UInt32 + ) -> String { + inWallet + ? "\(providerAccountTypeName(accountType)) #\(index)" + : "not in this wallet" + } + + var operatorPublicKeyHex: String? { + operatorPublicKey.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var platformNodeIdHex: String? { + platformNodeId.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var collateralDisplay: String? { + guard let txid = collateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(collateralVout)" + } + + var displayNumber: Int { + Int(typeIndex) + } + + var typeName: String { + isEvonode ? "Evonode" : "Masternode" + } + + var displayTitle: String { + "\(typeName) \(displayNumber)" + } + + var status: MasternodeStatus { + MasternodeStatus(rawValue: statusRaw) ?? .unknown + } + + var statusName: String { + status.displayName + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPendingInput.swift new file mode 100644 index 00000000000..3cfcbf9d49b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPendingInput.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +// `PersistentPendingInput` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentPendingInput { + #Index([\.outpoint], [\.walletId]) + var outpoint: Data + + var inputIndex: UInt32 + + var spendingTxid: Data + + var spendingTransaction: PersistentTransaction? + + var walletId: Data + + var createdAt: Date + + init( + outpoint: Data, + inputIndex: UInt32, + spendingTxid: Data, + spendingTransaction: PersistentTransaction?, + walletId: Data + ) { + self.outpoint = outpoint + self.inputIndex = inputIndex + self.spendingTxid = spendingTxid + self.spendingTransaction = spendingTransaction + self.walletId = walletId + self.createdAt = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddress.swift new file mode 100644 index 00000000000..0dc4151354b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddress.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddress` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentPlatformAddress { + #Index([\.walletId]) + + @Attribute(.unique) var address: String + var addressType: UInt8 + @Attribute(.unique) var addressHash: Data + var publicKey: Data + var accountIndex: UInt32 + var addressIndex: UInt32 + var derivationPath: String + var isUsed: Bool + var balance: UInt64 + var nonce: UInt32 + var firstSeenHeight: UInt32 + var lastSeenHeight: UInt64 + var walletId: Data + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + + init( + address: String, + addressType: UInt8, + addressHash: Data, + publicKey: Data = Data(), + accountIndex: UInt32, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0, + nonce: UInt32 = 0, + walletId: Data + ) { + self.address = address + self.addressType = addressType + self.addressHash = addressHash + self.publicKey = publicKey + self.accountIndex = accountIndex + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.balance = balance + self.nonce = nonce + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.walletId = walletId + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV1.PersistentPlatformAddress { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { entry in + entry.balance > 0 + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddressesSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddressesSyncState.swift new file mode 100644 index 00000000000..7cb00e9f645 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddressesSyncState.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddressesSyncState` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentPlatformAddressesSyncState { + @Attribute(.unique) var walletId: Data + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + var syncHeight: UInt64 + var syncTimestamp: UInt64 + var lastKnownRecentBlock: UInt64 + var lastUpdated: Date + + init( + walletId: Data, + network: Network, + syncHeight: UInt64, + syncTimestamp: UInt64, + lastKnownRecentBlock: UInt64 + ) { + self.walletId = walletId + self.networkRaw = network.rawValue + self.syncHeight = syncHeight + self.syncTimestamp = syncTimestamp + self.lastKnownRecentBlock = lastKnownRecentBlock + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentProperty.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentProperty.swift new file mode 100644 index 00000000000..47078ef4b2b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentProperty.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftData + +// `PersistentProperty` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentProperty { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + var type: String + var format: String? + var contentMediaType: String? + var byteArray: Bool + var minItems: Int? + var maxItems: Int? + var pattern: String? + var minLength: Int? + var maxLength: Int? + var minValue: Int? + var maxValue: Int? + var fieldDescription: String? + + var transient: Bool + var isRequired: Bool + + var createdAt: Date + + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, type: String) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.type = type + self.byteArray = false + self.transient = false + self.isRequired = false + self.createdAt = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPublicKey.swift new file mode 100644 index 00000000000..a776c17838c --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPublicKey.swift @@ -0,0 +1,171 @@ +import Foundation +import SwiftData + +// `PersistentPublicKey` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentPublicKey { + var keyId: Int32 + var purpose: String + var securityLevel: String + var keyType: String + var readOnly: Bool + var disabledAt: Int64? + + var publicKeyData: Data + + var contractBoundsData: Data? + + var contractBoundsDocumentTypeName: String? + + var privateKeyKeychainIdentifier: String? + + var walletId: Data? + + var identityDerivationPath: String? + + var identityId: String + var createdAt: Date + var lastAccessed: Date? + + @Relationship(inverse: \PersistentIdentity.publicKeys) + var identity: PersistentIdentity? + + init( + keyId: Int32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + publicKeyData: Data, + readOnly: Bool = false, + disabledAt: Int64? = nil, + contractBounds: [Data]? = nil, + contractBoundsDocumentTypeName: String? = nil, + identityId: String + ) { + self.keyId = keyId + self.purpose = String(purpose.rawValue) + self.securityLevel = String(securityLevel.rawValue) + self.keyType = String(keyType.rawValue) + self.publicKeyData = publicKeyData + self.readOnly = readOnly + self.disabledAt = disabledAt + if let contractBounds = contractBounds { + self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) + } else { + self.contractBoundsData = nil + } + self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName + self.identityId = identityId + self.createdAt = Date() + } + + var contractBounds: [Data]? { + get { + guard let data = contractBoundsData, + let json = try? JSONSerialization.jsonObject(with: data), + let strings = json as? [String] else { + return nil + } + return strings.compactMap { Data(base64Encoded: $0) } + } + set { + contractBoundsDocumentTypeName = nil + if let newValue = newValue { + contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) + } else { + contractBoundsData = nil + } + } + } + + var purposeEnum: KeyPurpose? { + guard let purposeInt = UInt8(purpose) else { return nil } + return KeyPurpose(rawValue: purposeInt) + } + + var securityLevelEnum: SecurityLevel? { + guard let levelInt = UInt8(securityLevel) else { return nil } + return SecurityLevel(rawValue: levelInt) + } + + var keyTypeEnum: KeyType? { + guard let typeInt = UInt8(keyType) else { return nil } + return KeyType(rawValue: typeInt) + } + + var isDisabled: Bool { + disabledAt != nil + } + + var hasPrivateKeyIdentifier: Bool { + privateKeyKeychainIdentifier != nil + } + } +} + +extension DashSchemaV1.PersistentPublicKey { + func toIdentityPublicKey() -> IdentityPublicKey? { + guard let purpose = purposeEnum, + let securityLevel = securityLevelEnum, + let keyType = keyTypeEnum else { + return nil + } + + let bounds: ContractBounds? + if let id = contractBounds?.first, id.count == 32 { + if let docTypeName = contractBoundsDocumentTypeName, !docTypeName.isEmpty { + bounds = .singleContractDocumentType(id: id, documentTypeName: docTypeName) + } else { + bounds = .singleContract(id: id) + } + } else { + bounds = nil + } + + return IdentityPublicKey( + id: KeyID(keyId), + purpose: purpose, + securityLevel: securityLevel, + contractBounds: bounds, + keyType: keyType, + readOnly: readOnly, + data: publicKeyData, + disabledAt: disabledAt.map { TimestampMillis($0) } + ) + } + + static func from(_ publicKey: IdentityPublicKey, identityId: String) -> DashSchemaV1.PersistentPublicKey? { + let boundsIds: [Data]? + let docTypeName: String? + switch publicKey.contractBounds { + case .singleContract(let id): + boundsIds = [id] + docTypeName = nil + case .singleContractDocumentType(let id, let name): + boundsIds = [id] + docTypeName = name + case .none: + boundsIds = nil + docTypeName = nil + } + return DashSchemaV1.PersistentPublicKey( + keyId: Int32(publicKey.id), + purpose: publicKey.purpose, + securityLevel: publicKey.securityLevel, + keyType: publicKey.keyType, + publicKeyData: publicKey.data, + readOnly: publicKey.readOnly, + disabledAt: publicKey.disabledAt.map { Int64($0) }, + contractBounds: boundsIds, + contractBoundsDocumentTypeName: docTypeName, + identityId: identityId + ) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedActivity.swift new file mode 100644 index 00000000000..bef8fa09fe2 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedActivity.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftData + +// `PersistentShieldedActivity` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentShieldedActivity { + #Unique([\.walletId, \.accountIndex, \.entryId]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var entryId: Data + + var kindTag: Int + var direction: Int + var status: Int + + var amount: UInt64 + var fee: UInt64 + var hasFee: Bool + var blockHeight: UInt64 + var hasBlockHeight: Bool + var createdAtMs: UInt64 + + var minNotePosition: UInt64 = 0 + var hasMinNotePosition: Bool = false + + var identityId: Data + var counterparty: Data + var memo: Data + var noteCmxs: Data + var spentNullifiers: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + entryId: Data, + kindTag: Int, + direction: Int, + status: Int, + amount: UInt64, + fee: UInt64, + hasFee: Bool, + blockHeight: UInt64, + hasBlockHeight: Bool, + createdAtMs: UInt64, + minNotePosition: UInt64 = 0, + hasMinNotePosition: Bool = false, + identityId: Data, + counterparty: Data, + memo: Data, + noteCmxs: Data, + spentNullifiers: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.entryId = entryId + self.kindTag = kindTag + self.direction = direction + self.status = status + self.amount = amount + self.fee = fee + self.hasFee = hasFee + self.blockHeight = blockHeight + self.hasBlockHeight = hasBlockHeight + self.createdAtMs = createdAtMs + self.minNotePosition = minNotePosition + self.hasMinNotePosition = hasMinNotePosition + self.identityId = identityId + self.counterparty = counterparty + self.memo = memo + self.noteCmxs = noteCmxs + self.spentNullifiers = spentNullifiers + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedNote.swift new file mode 100644 index 00000000000..958ab71a5b0 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedNote.swift @@ -0,0 +1,66 @@ +import Foundation +import SwiftData + +// `PersistentShieldedNote` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentShieldedNote { + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var position: UInt64 + var cmx: Data + @Attribute(.unique) var nullifier: Data + var blockHeight: UInt64 + var isSpent: Bool + var value: UInt64 + var noteData: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + position: UInt64, + cmx: Data, + nullifier: Data, + blockHeight: UInt64, + isSpent: Bool, + value: UInt64, + noteData: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.position = position + self.cmx = cmx + self.nullifier = nullifier + self.blockHeight = blockHeight + self.isSpent = isSpent + self.value = value + self.noteData = noteData + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} + +extension DashSchemaV1.PersistentShieldedNote { + static func unspentPredicate(walletId: Data) -> Predicate { + #Predicate { + $0.walletId == walletId && $0.isSpent == false + } + } + + static var unspentPredicate: Predicate { + #Predicate { $0.isSpent == false } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedOutgoingNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedOutgoingNote.swift new file mode 100644 index 00000000000..6a072dc0ba3 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedOutgoingNote.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftData + +// `PersistentShieldedOutgoingNote` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentShieldedOutgoingNote { + #Unique([\.walletId, \.accountIndex, \.cmx]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var cmx: Data + var recipient: Data + var value: UInt64 + var memo: Data + var blockHeight: UInt64 + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + cmx: Data, + recipient: Data, + value: UInt64, + memo: Data, + blockHeight: UInt64 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.cmx = cmx + self.recipient = recipient + self.value = value + self.memo = memo + self.blockHeight = blockHeight + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedSyncState.swift new file mode 100644 index 00000000000..c7a2249536f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedSyncState.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedSyncState` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentShieldedSyncState { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + var lastSyncedIndex: UInt64 + + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + lastSyncedIndex: UInt64 = 0 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.lastSyncedIndex = lastSyncedIndex + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedViewingKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedViewingKey.swift new file mode 100644 index 00000000000..a310cef1a10 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedViewingKey.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedViewingKey` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentShieldedViewingKey { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + var fvkBytes: Data + + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + fvkBytes: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.fvkBytes = fvkBytes + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentToken.swift new file mode 100644 index 00000000000..1fd29d57342 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentToken.swift @@ -0,0 +1,376 @@ +import Foundation +import SwiftData + +// `PersistentToken` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentToken { + @Attribute(.unique) var id: Data + var contractId: Data + var position: Int + var name: String + + var baseSupply: String + var maxSupply: String? + var decimals: Int + + var localizations: [String: TokenLocalization]? + + var isPaused: Bool + var allowTransferToFrozenBalance: Bool + + var keepsTransferHistory: Bool + var keepsFreezingHistory: Bool + var keepsMintingHistory: Bool + var keepsBurningHistory: Bool + var keepsDirectPricingHistory: Bool + var keepsDirectPurchaseHistory: Bool + + var conventionsChangeRules: ChangeControlRules? + var maxSupplyChangeRules: ChangeControlRules? + var manualMintingRules: ChangeControlRules? + var manualBurningRules: ChangeControlRules? + var freezeRules: ChangeControlRules? + var unfreezeRules: ChangeControlRules? + var destroyFrozenFundsRules: ChangeControlRules? + var emergencyActionRules: ChangeControlRules? + + var perpetualDistribution: TokenPerpetualDistribution? + var preProgrammedDistribution: TokenPreProgrammedDistribution? + var newTokensDestinationIdentity: Data? + var mintingAllowChoosingDestination: Bool + var distributionChangeRules: TokenDistributionChangeRules? + + var tradeMode: TokenTradeMode + var tradeModeChangeRules: ChangeControlRules? + + var mainControlGroupPosition: Int? + var mainControlGroupCanBeModified: String? + + var tokenDescription: String? + + var createdAt: Date + var lastUpdatedAt: Date + + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade) + var balances: [PersistentTokenBalance]? + + @Relationship(deleteRule: .cascade) + var historyEvents: [PersistentTokenHistoryEvent]? + + init(contractId: Data, position: Int, name: String, baseSupply: String, decimals: Int = 8) { + var idData = contractId + withUnsafeBytes(of: position.bigEndian) { bytes in + idData.append(contentsOf: bytes) + } + self.id = idData + + self.contractId = contractId + self.position = position + self.name = name + self.baseSupply = baseSupply + self.decimals = decimals + + self.isPaused = false + self.allowTransferToFrozenBalance = true + self.keepsTransferHistory = true + self.keepsFreezingHistory = true + self.keepsMintingHistory = true + self.keepsBurningHistory = true + self.keepsDirectPricingHistory = true + self.keepsDirectPurchaseHistory = true + self.mintingAllowChoosingDestination = true + self.tradeMode = TokenTradeMode.notTradeable + + self.createdAt = Date() + self.lastUpdatedAt = Date() + } + } +} + +extension DashSchemaV1.PersistentToken { + var displayName: String { + if let desc = tokenDescription, !desc.isEmpty { + return desc + } + return getSingularForm() ?? name + } + + var formattedBaseSupply: String { + Self.formatSupply(baseSupply, decimals: decimals) + } + + static func formatSupply(_ raw: String, decimals: Int) -> String { + guard !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }) else { + return raw + } + let normalized = String(raw.drop(while: { $0 == "0" })) + let digits = normalized.isEmpty ? "0" : normalized + let scale = max(0, decimals) + let integer: String + var fraction = "" + if scale == 0 { + integer = digits + } else if digits.count <= scale { + integer = "0" + fraction = String(repeating: "0", count: scale - digits.count) + digits + } else { + let split = digits.index(digits.endIndex, offsetBy: -scale) + integer = String(digits[.. 0 && offset.isMultiple(of: 3) { grouped.append(",") } + grouped.append(character) + } + grouped = String(grouped.reversed()) + return fraction.isEmpty ? grouped : "\(grouped).\(fraction)" + } + + var contractIdBase58: String { + contractId.toBase58String() + } + + var canManuallyMint: Bool { + manualMintingRules != nil + } + + var canManuallyBurn: Bool { + manualBurningRules != nil + } + + var canFreeze: Bool { + freezeRules != nil + } + + var canUnfreeze: Bool { + unfreezeRules != nil + } + + var canDestroyFrozenFunds: Bool { + destroyFrozenFundsRules != nil + } + + var hasEmergencyActions: Bool { + emergencyActionRules != nil + } + + var canChangeMaxSupply: Bool { + maxSupplyChangeRules != nil + } + + var canChangeConventions: Bool { + conventionsChangeRules != nil + } + + var hasDistribution: Bool { + perpetualDistribution != nil || preProgrammedDistribution != nil + } + + var canChangeTradeMode: Bool { + tradeModeChangeRules != nil + } + + var keepsAnyHistory: Bool { + keepsTransferHistory || + keepsFreezingHistory || + keepsMintingHistory || + keepsBurningHistory || + keepsDirectPricingHistory || + keepsDirectPurchaseHistory + } + + var totalSupply: String { + guard let balances = balances, !balances.isEmpty else { return baseSupply } + return Self.sumUnsignedBalances(balances.map(\.unsignedBalance)) + } + + var totalFrozenBalance: String { + guard let balances = balances else { return "0" } + return Self.sumUnsignedBalances( + balances.lazy.filter(\.frozen).map(\.unsignedBalance) + ) + } + + var activeHolders: Int { + balances?.filter { $0.unsignedBalance > 0 }.count ?? 0 + } + + private static func sumUnsignedBalances(_ values: S) -> String + where S.Element == UInt64 { + var digits: [UInt8] = [0] // little-endian decimal digits + + for value in values { + var carry = 0 + let addend = String(value).utf8.reversed().map { Int($0 - 48) } + let width = max(digits.count, addend.count) + if digits.count < width { + digits.append(contentsOf: repeatElement(0, count: width - digits.count)) + } + + for index in 0.. 0 { + digits.append(UInt8(carry % 10)) + carry /= 10 + } + } + + return String(digits.reversed().map { Character(String($0)) }) + } + + var hasMaxSupply: Bool { + maxSupply != nil + } + + var isTradeable: Bool { + tradeMode != .notTradeable + } + + var newTokensDestinationIdentityBase58: String? { + newTokensDestinationIdentity?.toBase58String() + } +} + +extension DashSchemaV1.PersistentToken { + func setLocalization(languageCode: String, singularForm: String, pluralForm: String, description: String? = nil) { + if localizations == nil { + localizations = [:] + } + localizations?[languageCode] = DashSchemaV1.TokenLocalization( + singularForm: singularForm, + pluralForm: pluralForm, + description: description + ) + lastUpdatedAt = Date() + } + + func getSingularForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.singularForm ?? localizations?["en"]?.singularForm + } + + func getPluralForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.pluralForm ?? localizations?["en"]?.pluralForm + } +} + +extension DashSchemaV1.PersistentToken { + func getChangeControlRules(for type: ChangeControlRuleType) -> DashSchemaV1.ChangeControlRules? { + switch type { + case .conventions: return conventionsChangeRules + case .maxSupply: return maxSupplyChangeRules + case .manualMinting: return manualMintingRules + case .manualBurning: return manualBurningRules + case .freeze: return freezeRules + case .unfreeze: return unfreezeRules + case .destroyFrozenFunds: return destroyFrozenFundsRules + case .emergencyAction: return emergencyActionRules + case .tradeMode: return tradeModeChangeRules + } + } + + func setChangeControlRules(_ rules: DashSchemaV1.ChangeControlRules, for type: ChangeControlRuleType) { + switch type { + case .conventions: conventionsChangeRules = rules + case .maxSupply: maxSupplyChangeRules = rules + case .manualMinting: manualMintingRules = rules + case .manualBurning: manualBurningRules = rules + case .freeze: freezeRules = rules + case .unfreeze: unfreezeRules = rules + case .destroyFrozenFunds: destroyFrozenFundsRules = rules + case .emergencyAction: emergencyActionRules = rules + case .tradeMode: tradeModeChangeRules = rules + } + + lastUpdatedAt = Date() + } +} + +extension DashSchemaV1.PersistentToken { + static func mintableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualMintingRules != nil + } + } + + static func burnableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualBurningRules != nil + } + } + + static func freezableTokensPredicate() -> Predicate { + #Predicate { token in + token.freezeRules != nil + } + } + + static func distributionTokensPredicate() -> Predicate { + #Predicate { token in + token.perpetualDistribution != nil || token.preProgrammedDistribution != nil + } + } + + static func pausedTokensPredicate() -> Predicate { + #Predicate { token in + token.isPaused == true + } + } + + static func tokensByContractPredicate(contractId: Data) -> Predicate { + #Predicate { token in + token.contractId == contractId + } + } + + static func tokensWithControlRulePredicate(rule: ControlRuleType) -> Predicate { + switch rule { + case .manualMinting: + return #Predicate { token in + token.manualMintingRules != nil + } + case .manualBurning: + return #Predicate { token in + token.manualBurningRules != nil + } + case .freeze: + return #Predicate { token in + token.freezeRules != nil + } + case .unfreeze: + return #Predicate { token in + token.unfreezeRules != nil + } + case .destroyFrozenFunds: + return #Predicate { token in + token.destroyFrozenFundsRules != nil + } + case .emergencyAction: + return #Predicate { token in + token.emergencyActionRules != nil + } + case .conventions: + return #Predicate { token in + token.conventionsChangeRules != nil + } + case .maxSupply: + return #Predicate { token in + token.maxSupplyChangeRules != nil + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenBalance.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenBalance.swift new file mode 100644 index 00000000000..bb8fff89311 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenBalance.swift @@ -0,0 +1,198 @@ +import Foundation +import SwiftData + +// `PersistentTokenBalance` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentTokenBalance { + #Index([\.networkRaw]) + + var tokenId: String + var identityId: Data + var balance: Int64 + var frozen: Bool + + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + var tokenName: String? + var tokenSymbol: String? + var tokenDecimals: Int32? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? + @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? + + init( + tokenId: String, + identityId: Data, + balance: Int64 = 0, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.tokenId = tokenId + self.identityId = identityId + self.balance = balance + self.frozen = frozen + self.tokenName = tokenName + self.tokenSymbol = tokenSymbol + self.tokenDecimals = tokenDecimals + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + self.networkRaw = network.rawValue + } + + convenience init( + tokenId: String, + identityId: Data, + unsignedBalance: UInt64, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.init( + tokenId: tokenId, + identityId: identityId, + balance: Int64(bitPattern: unsignedBalance), + frozen: frozen, + tokenName: tokenName, + tokenSymbol: tokenSymbol, + tokenDecimals: tokenDecimals, + network: network + ) + } + + var unsignedBalance: UInt64 { + get { UInt64(bitPattern: balance) } + set { balance = Int64(bitPattern: newValue) } + } + + var formattedBalance: String { + let decimals: Int + if let tokenDecimals { + decimals = Int(tokenDecimals) + } else if let tokenDecimals = token?.decimals { + decimals = tokenDecimals + } else { + return "\(unsignedBalance)" + } + + guard decimals > 0 else { return String(unsignedBalance) } + + let digits = String(unsignedBalance) + let scale = decimals + if digits.count <= scale { + return "0." + String(repeating: "0", count: scale - digits.count) + digits + } + let split = digits.index(digits.endIndex, offsetBy: -scale) + return String(digits[.. (tokenId: String, balance: UInt64, frozen: Bool) { + return (tokenId: tokenId, balance: unsignedBalance, frozen: frozen) + } +} + +extension DashSchemaV1.PersistentTokenBalance { + static func predicate(tokenId: String, identityId: Data) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId && balance.identityId == identityId + } + } + + static func predicate(identityId: Data) -> Predicate { + #Predicate { balance in + balance.identityId == identityId + } + } + + static func predicate(tokenId: String) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { balance in + balance.balance != 0 + } + } + + static var frozenBalancesPredicate: Predicate { + #Predicate { balance in + balance.frozen == true + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { balance in + balance.lastSyncedAt == nil || balance.lastSyncedAt! < date + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenHistoryEvent.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenHistoryEvent.swift new file mode 100644 index 00000000000..4099c37a8de --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenHistoryEvent.swift @@ -0,0 +1,112 @@ +import Foundation +import SwiftData + +// `PersistentTokenHistoryEvent` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentTokenHistoryEvent { + @Attribute(.unique) var id: UUID + + var eventType: String + var transactionId: Data? + var blockHeight: Int64? + var coreBlockHeight: Int64? + + var fromIdentity: Data? + var toIdentity: Data? + var performedByIdentity: Data + + var amount: String? + var balanceBefore: String? + var balanceAfter: String? + + var additionalDataJSON: Data? + + var eventDescription: String? + + var createdAt: Date + var eventTimestamp: Date + + @Relationship(inverse: \PersistentToken.historyEvents) + var token: PersistentToken? + + init( + eventType: TokenEventType, + performedByIdentity: Data, + eventTimestamp: Date = Date() + ) { + self.id = UUID() + self.eventType = eventType.rawValue + self.performedByIdentity = performedByIdentity + self.eventTimestamp = eventTimestamp + self.createdAt = Date() + } + + var eventTypeEnum: TokenEventType { + TokenEventType(rawValue: eventType) ?? .unknown + } + + var fromIdentityBase58: String? { + fromIdentity?.toBase58String() + } + + var toIdentityBase58: String? { + toIdentity?.toBase58String() + } + + var performedByIdentityBase58: String { + performedByIdentity.toBase58String() + } + + var displayTitle: String { + switch eventTypeEnum { + case .mint: + return "Minted \(formattedAmount)" + case .burn: + return "Burned \(formattedAmount)" + case .transfer: + return "Transfer \(formattedAmount)" + case .freeze: + return "Frozen \(formattedAmount)" + case .unfreeze: + return "Unfrozen \(formattedAmount)" + case .destroyFrozenFunds: + return "Destroyed Frozen Funds \(formattedAmount)" + case .configUpdate: + return "Configuration Updated" + case .emergencyAction: + return "Emergency Action" + case .perpetualDistribution: + return "Perpetual Distribution \(formattedAmount)" + case .preProgrammedRelease: + return "Pre-programmed Release \(formattedAmount)" + case .directPricing: + return "Direct Pricing Updated" + case .directPurchase: + return "Direct Purchase \(formattedAmount)" + case .unknown: + return "Unknown Event" + } + } + + private var formattedAmount: String { + guard let amount = amount else { return "" } + return amount + } + + func setAdditionalData(_ data: [String: Any]) { + additionalDataJSON = try? JSONSerialization.data(withJSONObject: data) + } + + func getAdditionalData() -> [String: Any]? { + guard let data = additionalDataJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTransaction.swift new file mode 100644 index 00000000000..bd120f321d5 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTransaction.swift @@ -0,0 +1,167 @@ +import Foundation +import SwiftData + +// `PersistentTransaction` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentTransaction { + #Index([\.firstSeen]) + + @Attribute(.unique) var txid: Data + var transactionData: Data + var context: UInt32 + var blockHeight: UInt32 + var blockHash: Data? + var blockTimestamp: UInt32 + var blockPosition: UInt32 = 0 + var hasBlockPosition: Bool = false + var direction: UInt32 + var transactionType: String + var transactionTypeKind: UInt8 = 0xFF + var netAmount: Int64 + var fee: UInt64? + var label: String + var firstSeen: UInt64 + + var providerServiceAddress: String? = nil + var providerProTxHash: Data? = nil + var providerCollateralTxid: Data? = nil + var providerCollateralVout: UInt32 = 0 + var providerOwnerKeyHash: Data? = nil + var providerVotingKeyHash: Data? = nil + + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) + var outputs: [PersistentTxo] = [] + + @Relationship(inverse: \PersistentTxo.spendingTransaction) + var inputs: [PersistentTxo] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) + var pendingInputs: [PersistentPendingInput] = [] + + @Relationship(inverse: \PersistentAccount.involvedTransactions) + var involvedAccounts: [PersistentAccount] = [] + + init( + txid: Data, + transactionData: Data, + context: UInt32 = 0, + blockHeight: UInt32 = 0, + direction: UInt32 = 0, + transactionType: String = "Standard", + netAmount: Int64 = 0, + firstSeen: UInt64 = 0 + ) { + self.txid = txid + self.transactionData = transactionData + self.context = context + self.blockHeight = blockHeight + self.blockTimestamp = 0 + self.direction = direction + self.transactionType = transactionType + self.netAmount = netAmount + self.firstSeen = firstSeen + self.label = "" + self.createdAt = Date() + self.lastUpdated = Date() + } + + var txidHex: String { + txid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var contextName: String { + switch context { + case 0: return "Mempool" + case 1: return "InstantSend" + case 2: return "In Block" + case 3: return "Chain Locked" + default: return "Unknown" + } + } + + var directionName: String { + switch direction { + case 0: return "Incoming" + case 1: return "Outgoing" + case 2: return "Internal" + case 3: return "CoinJoin" + default: return "Unknown" + } + } + + var typedKind: TransactionTypeKind? { + TransactionTypeKind(rawValue: transactionTypeKind) + } + + var isAssetLock: Bool { + typedKind == .assetLock + } + + var isAssetUnlock: Bool { + typedKind == .assetUnlock + } + + var isProviderRegistration: Bool { + typedKind == .providerRegistration + } + + var isProviderUpdateService: Bool { + typedKind == .providerUpdateService + } + + var providerProTxHashHex: String? { + providerProTxHash.map { $0.reversed().map { String(format: "%02x", $0) }.joined() } + } + + var providerCollateralDisplay: String? { + guard let txid = providerCollateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(providerCollateralVout)" + } + + var providerOwnerKeyHashHex: String? { + providerOwnerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var providerVotingKeyHashHex: String? { + providerVotingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var isProviderSpecial: Bool { + providerSpecialName != nil + } + + var providerSpecialName: String? { + switch typedKind { + case .providerRegistration: return "Provider Registration" + case .providerUpdateRegistrar: return "Provider Update Registrar" + case .providerUpdateService: return "Provider Update Service" + case .providerUpdateRevocation: return "Provider Update Revocation" + default: return nil + } + } + + var displayDirection: String { + if isAssetLock { return "Asset Lock" } + if isAssetUnlock { return "Asset Unlock" } + if let name = providerSpecialName { return name } + return directionName + } + + var formattedAmount: String { + let dash = Double(abs(netAmount)) / 100_000_000.0 + let sign = netAmount >= 0 ? "+" : "-" + return String(format: "%@%.8f DASH", sign, dash) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTxo.swift new file mode 100644 index 00000000000..7183c9a61cb --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTxo.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +// `PersistentTxo` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentTxo { + #Index([\.walletId]) + + @Attribute(.unique) var outpoint: Data + var vout: UInt32 + var amount: UInt64 + var address: String + var scriptPubKey: Data + var height: UInt32 + var isCoinbase: Bool + var isConfirmed: Bool + var isInstantLocked: Bool + var isLocked: Bool + var isSpent: Bool + var createdAt: Date + var lastUpdated: Date + + var walletId: Data = Data() + + var transaction: PersistentTransaction? + + var spendingTransaction: PersistentTransaction? + + var spendingInputIndex: UInt32? = nil + + var account: PersistentAccount? + + var coreAddress: PersistentCoreAddress? + + init( + transaction: PersistentTransaction, + vout: UInt32, + amount: UInt64, + address: String, + scriptPubKey: Data = Data(), + height: UInt32 = 0 + ) { + self.outpoint = Self.makeOutpoint(txid: transaction.txid, vout: vout) + self.vout = vout + self.amount = amount + self.address = address + self.scriptPubKey = scriptPubKey + self.height = height + self.isCoinbase = false + self.isConfirmed = false + self.isInstantLocked = false + self.isLocked = false + self.isSpent = false + self.createdAt = Date() + self.lastUpdated = Date() + self.transaction = transaction + } + + static func makeOutpoint(txid: Data, vout: UInt32) -> Data { + var data = Data(capacity: 36) + data.append(txid) + var v = vout.littleEndian + withUnsafeBytes(of: &v) { data.append(contentsOf: $0) } + return data + } + + var txid: Data { + if let transaction { + return transaction.txid + } + return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() + } + + var txidHex: String { + let rawTxid = txid + guard rawTxid.count == 32 else { return "" } + return rawTxid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var outpointHex: String { + let hex = txidHex + return hex.isEmpty ? "" : "\(hex):\(vout)" + } + + var formattedAmount: String { + let dash = Double(amount) / 100_000_000.0 + return String(format: "%.8f DASH", dash) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWallet.swift new file mode 100644 index 00000000000..af68909c2e1 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWallet.swift @@ -0,0 +1,94 @@ +import Foundation +import SwiftData + +// `PersistentWallet` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentWallet { + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + var walletId: Data + var walletGroupId: Data = Data() + var networkRaw: UInt32? + + var network: Network? { + get { + guard let raw = networkRaw else { return nil } + return Network(rawValue: raw) ?? .testnet + } + set { networkRaw = newValue?.rawValue } + } + var name: String? + var walletDescription: String? + var birthHeight: UInt32 + var syncedHeight: UInt32 + var lastSynced: UInt64 + var lastAppliedChainLockBytes: Data? + var isImported: Bool = false + var seedBindingVerifiedMarker: String? + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) + var accounts: [PersistentAccount] + + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.wallet) + var identities: [PersistentIdentity] + + init( + walletId: Data, + walletGroupId: Data = Data(), + network: Network? = nil, + name: String? = nil, + walletDescription: String? = nil, + birthHeight: UInt32 = 0, + syncedHeight: UInt32 = 0, + isImported: Bool = false + ) { + self.walletId = walletId + self.walletGroupId = walletGroupId + self.networkRaw = network?.rawValue + self.name = name + self.walletDescription = walletDescription + self.birthHeight = birthHeight + self.syncedHeight = syncedHeight + self.lastSynced = 0 + self.isImported = isImported + self.createdAt = Date() + self.lastUpdated = Date() + self.accounts = [] + self.identities = [] + } + } +} + +extension DashSchemaV1.PersistentWallet { + var label: String { + if let name = name, !name.isEmpty { + return name + } + let hex = walletId.prefix(4) + .map { String(format: "%02x", $0) } + .joined() + return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" + } +} + +extension DashSchemaV1.PersistentWallet { + static func predicate(walletId: Data) -> Predicate { + #Predicate { $0.walletId == walletId } + } + + static func predicate( + walletGroupId: Data + ) -> Predicate { + #Predicate { $0.walletGroupId == walletGroupId } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWalletManagerMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWalletManagerMetadata.swift new file mode 100644 index 00000000000..e4967235f28 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWalletManagerMetadata.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentWalletManagerMetadata` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV1 { + @Model + final class PersistentWalletManagerMetadata { + @Attribute(.unique) var networkRaw: UInt32 + var combinedSyncHeight: UInt32 + var combinedSyncBlockHash: Data? + var walletCount: Int + var createdAt: Date + var lastUpdated: Date + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + init(network: Network) { + self.networkRaw = network.rawValue + self.combinedSyncHeight = 0 + self.walletCount = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+TokenTypes.swift new file mode 100644 index 00000000000..16d251e4946 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+TokenTypes.swift @@ -0,0 +1,153 @@ +import Foundation +import SwiftData + +// Inline value types exactly as schema DashSchemaV1 stored them, generated +// by scripts/freeze_schema_models.py from TokenTypes.swift +// at commit 96a103375e. SwiftData expands a stored Codable struct into composite +// attributes of the owning entity, so these shapes are inputs to that +// version's checksum just like the model's own properties. Do not edit. +extension DashSchemaV1 { + struct ChangeControlRules: Codable, Equatable, Sendable { + var authorizedToMakeChange: String + var adminActionTakers: String + var changingAuthorizedActionTakersToNoOneAllowed: Bool + var changingAdminActionTakersToNoOneAllowed: Bool + var selfChangingAdminActionTakersAllowed: Bool + + init( + authorizedToMakeChange: String = AuthorizedActionTakers.noOne.rawValue, + adminActionTakers: String = AuthorizedActionTakers.noOne.rawValue, + changingAuthorizedActionTakersToNoOneAllowed: Bool = false, + changingAdminActionTakersToNoOneAllowed: Bool = false, + selfChangingAdminActionTakersAllowed: Bool = false + ) { + self.authorizedToMakeChange = authorizedToMakeChange + self.adminActionTakers = adminActionTakers + self.changingAuthorizedActionTakersToNoOneAllowed = changingAuthorizedActionTakersToNoOneAllowed + self.changingAdminActionTakersToNoOneAllowed = changingAdminActionTakersToNoOneAllowed + self.selfChangingAdminActionTakersAllowed = selfChangingAdminActionTakersAllowed + } + + static func mostRestrictive() -> ChangeControlRules { + return ChangeControlRules() + } + + static func contractOwnerControlled() -> ChangeControlRules { + return ChangeControlRules( + authorizedToMakeChange: AuthorizedActionTakers.contractOwner.rawValue, + adminActionTakers: AuthorizedActionTakers.noOne.rawValue, + selfChangingAdminActionTakersAllowed: true + ) + } + } + + enum AuthorizedActionTakers: String, CaseIterable, Codable, Sendable { + case noOne = "NoOne" + case contractOwner = "ContractOwner" + case mainGroup = "MainGroup" + + static func identity(_ id: Data) -> String { + return "Identity:\(id.toBase58String())" + } + + static func group(_ position: Int) -> String { + return "Group:\(position)" + } + } + + struct TokenPerpetualDistribution: Codable, Equatable, Sendable { + var distributionType: String + var distributionRecipient: String + var enabled: Bool + var lastDistributionTime: Date? + var nextDistributionTime: Date? + + init(distributionRecipient: String = "AllEqualShare", enabled: Bool = true) { + self.distributionType = "{}" + self.distributionRecipient = distributionRecipient + self.enabled = enabled + } + } + + struct TokenPreProgrammedDistribution: Codable, Equatable, Sendable { + var distributionSchedule: [DistributionEvent] + var currentEventIndex: Int + var totalDistributed: String + var remainingToDistribute: String + var isActive: Bool + var isPaused: Bool + var isCompleted: Bool + + init() { + self.distributionSchedule = [] + self.currentEventIndex = 0 + self.totalDistributed = "0" + self.remainingToDistribute = "0" + self.isActive = true + self.isPaused = false + self.isCompleted = false + } + } + + struct DistributionEvent: Codable, Equatable, Sendable { + var id: UUID + var triggerType: String + var triggerTime: Date? + var triggerBlock: Int64? + var triggerCondition: String? + var amount: String + var recipient: String + var description: String? + + init(triggerTime: Date, amount: String, recipient: String = "AllHolders", description: String? = nil) { + self.id = UUID() + self.triggerType = "Time" + self.triggerTime = triggerTime + self.amount = amount + self.recipient = recipient + self.description = description + } + } + + struct TokenDistributionChangeRules: Codable, Equatable, Sendable { + var perpetualDistributionRules: ChangeControlRules? + var newTokensDestinationIdentityRules: ChangeControlRules? + var mintingAllowChoosingDestinationRules: ChangeControlRules? + var changeDirectPurchasePricingRules: ChangeControlRules? + + init( + perpetualDistributionRules: ChangeControlRules? = nil, + newTokensDestinationIdentityRules: ChangeControlRules? = nil, + mintingAllowChoosingDestinationRules: ChangeControlRules? = nil, + changeDirectPurchasePricingRules: ChangeControlRules? = nil + ) { + self.perpetualDistributionRules = perpetualDistributionRules + self.newTokensDestinationIdentityRules = newTokensDestinationIdentityRules + self.mintingAllowChoosingDestinationRules = mintingAllowChoosingDestinationRules + self.changeDirectPurchasePricingRules = changeDirectPurchasePricingRules + } + } + + enum TokenTradeMode: String, CaseIterable, Codable, Sendable { + case notTradeable = "NotTradeable" + + var displayName: String { + switch self { + case .notTradeable: + return "Not Tradeable" + } + } + } + + struct TokenLocalization: Codable, Equatable, Sendable { + let singularForm: String + let pluralForm: String + let description: String? + + init(singularForm: String, pluralForm: String, description: String? = nil) { + self.singularForm = singularForm + self.pluralForm = pluralForm + self.description = description + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift new file mode 100644 index 00000000000..baf08fe0262 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +// `PersistentTrackedMasternode` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV2 { + @Model + final class PersistentTrackedMasternode { + #Unique([\.networkRaw, \.proTxHash]) + #Index([\.networkRaw]) + + var networkRaw: UInt32 + var proTxHash: Data + var label: String? + var addedAt: UInt64 + var snapshotJSON: String + + var network: Network? { + get { Network(rawValue: networkRaw) } + set { networkRaw = newValue?.rawValue ?? networkRaw } + } + + init( + networkRaw: UInt32, + proTxHash: Data, + label: String?, + addedAt: UInt64, + snapshotJSON: String + ) { + self.networkRaw = networkRaw + self.proTxHash = proTxHash + self.label = label + self.addedAt = addedAt + self.snapshotJSON = snapshotJSON + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift new file mode 100644 index 00000000000..0697929fc8e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift @@ -0,0 +1,102 @@ +import Foundation +import SwiftData + +// `PersistentAssetLock` exactly as schema DashSchemaV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 96a103375e. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Index / #Unique here is an input to +// that version's checksum, and changing any of them re-breaks the stores +// this copy exists to keep openable. See the live model for what each +// column means. +extension DashSchemaV3 { + @Model + final class PersistentAssetLock { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var walletId: Data + + var transactionBytes: Data + + var fundingTypeRaw: Int + + var identityIndexRaw: Int32 + + var accountIndexRaw: Int32 = 0 + + var amountDuffs: Int64 + + var statusRaw: Int + + var proofBytes: Data? + + var recipientPlatformAddressHash: Data? + + var recipientPlatformAddressType: UInt8? + + var recipientIsExternal: Bool? + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + walletId: Data, + transactionBytes: Data, + fundingTypeRaw: Int, + identityIndexRaw: Int32, + accountIndexRaw: Int32 = 0, + amountDuffs: Int64, + statusRaw: Int, + proofBytes: Data? = nil + ) { + self.outPointHex = outPointHex + self.walletId = walletId + self.transactionBytes = transactionBytes + self.fundingTypeRaw = fundingTypeRaw + self.identityIndexRaw = identityIndexRaw + self.accountIndexRaw = accountIndexRaw + self.amountDuffs = amountDuffs + self.statusRaw = statusRaw + self.proofBytes = proofBytes + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaV3.PersistentAssetLock { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static func predicate( + walletId: Data, + identityIndex: UInt32 + ) -> Predicate { + let identityIndexRaw = Int32(bitPattern: identityIndex) + return #Predicate { entry in + entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw + } + } +} + +extension DashSchemaV3.PersistentAssetLock { + static func encodeOutPoint(rawBytes: Data) -> String { + precondition(rawBytes.count == 36, "outpoint must be 36 bytes") + let txid = rawBytes.prefix(32) + let voutBytes = rawBytes.suffix(4) + let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in + var value: UInt32 = 0 + withUnsafeMutableBytes(of: &value) { dst in + dst.copyBytes(from: raw.prefix(MemoryLayout.size)) + } + return UInt32(littleEndian: value) + } + let txidHex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(txidHex):\(vout)" + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift index a3e5f5626de..f340cf34b79 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift @@ -78,6 +78,50 @@ public final class PersistentPendingInput { /// never resolved (orphans whose previous output isn't ours). public var createdAt: Date + /// Set when `applySweptTransaction` repurposes this row as a durable + /// claim rather than an ordinary in-flight spend: the original + /// spending transaction turned out to be a loser, this input wasn't in + /// `released`, and the funding `PersistentTxo` still hasn't arrived to + /// hold the claim itself. `spendingTxid` is overwritten to the winner + /// (`superseded_by`) and `spendingTransaction` is detached so the row + /// survives the loser's cascade-delete. `upsertUtxo` checks this flag + /// on resolve: a tombstone forces `PersistentTxo.isSpent = true` + /// unconditionally (a sweep's winner is already final, unlike an + /// ordinary pending spend whose confirmation is still pending) and + /// stamps `PersistentTxo.supersededByTxid` so the mark survives even + /// when the winner's own row never materializes. Defaulted `false` so + /// existing rows migrate as ordinary pending entries. + public var isSweptTombstone: Bool = false + + /// The WINNER'S own mined block height, stamped when a block-context + /// sweep (`SweepBatchFFI.has_winner_mined_height`) repurposes this row + /// into a tombstone — the projection of upstream key-wallet's + /// `observed_spent_outpoints`, which maps each outpoint observed spent + /// in a block to the height of the block that spent it and deliberately + /// records nothing for a mempool/IS-lock spend ("an unconfirmed spend + /// must not invalidate a coin"). Not an observation watermark: the + /// height rides the sweep event itself, so nothing here guesses when + /// the winner mined. It is the row's whole lifetime rule — + /// `collectFinalizedSweptTombstones` deletes the tombstone exactly when + /// the finality boundary `min(chainlockHeight, syncedHeight)` reaches + /// this stamp (upstream's `prune_finalized_observed_spends` condition + /// verbatim, no margin): every BIP158 filter at or below the boundary + /// has been matched with no false negatives, so the funding transaction + /// of the guarded outpoint — necessarily mined at or below the spend's + /// own height — has either been delivered (draining the row) or + /// provably never will be. A mempool-context sweep (IS-locked winner, + /// unmined) writes its tombstone with this NIL on purpose: under + /// DIP-10 the lock alone settles the input, but the winner has no + /// mining deadline, so no boundary can ever prove its funding output + /// delivered-or-never — the collector never touches an unstamped row, + /// and the hold lasts until the funding TXO drains it, a later + /// block-context sweep stamps it, or a release deletes it. + /// Re-pointing an existing tombstone on a mempool-context sweep keeps + /// the earlier block-context stamp untouched (upstream never retracts + /// an observed-spend entry for an unconfirmed conflict). + /// Optional, so existing stores lightweight-migrate. + public var winnerMinedHeight: UInt32? + public init( outpoint: Data, inputIndex: UInt32, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index f0ecd0fce34..654ec7e5c9d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -119,6 +119,23 @@ public final class PersistentTransaction { public var createdAt: Date public var lastUpdated: Date + /// Durable global exclusion for a swept loser. + /// + /// Set by `applySweptTransaction` in EVERY wallet's callback that + /// observes this row's sweep — not only the one whose deletion happens + /// to remove it. `store()` commits once per wallet, independently, so a + /// row `commit_batch` holds back for a second wallet's still-outstanding + /// claim cannot let that hold-back also postpone the parts of the sweep + /// that are true regardless of who else has weighed in: this flag is + /// what stays true the moment the first wallet's callback runs, so a + /// crash or rejection before any other wallet's callback arrives still + /// leaves the row excluded from every restore/enumeration path. `true` + /// means Rust has already proven the transaction can never confirm; + /// callers must treat the row as gone regardless of whether it still + /// physically exists (see `applySweptTransaction`'s doc for why the + /// physical delete is demoted to housekeeping once this is set). + public var isGloballySwept: Bool = false + /// Transaction outputs created by this transaction. /// /// Cascade-deletes the matching `PersistentTxo` rows when the diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift index 1775eda311e..d9031c439bf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift @@ -86,6 +86,28 @@ public final class PersistentTxo { /// the spending tx must not cascade-delete this row. public var spendingTransaction: PersistentTransaction? + /// 32-byte txid of the transaction a sweep's winner is known to have + /// beaten this coin to — the durable carrier of a sweep hold, + /// mirroring the SQLite store's `spent_in_txid`. Sweep processing sets + /// it when holding an already-materialized input, whether that input is + /// still linked to the loser or already linked to the winner; + /// `upsertUtxo` also sets it while resolving a `PersistentPendingInput` tombstone + /// (`isSweptTombstone`) — the funding output arrived only after its + /// loser was already swept and deleted. The winner named here need not + /// have a row of its own (it can pay only outside addresses), which is + /// why the stamp is a bare txid rather than a relationship. + /// + /// `upsertUtxo`'s recovery clear keys on it: a coin the wallet + /// re-delivers as unspent lifts `isSpent` only when both + /// `spendingTransaction` and this are nil — a rescan re-finds the + /// funding output precisely because it is blind to an unconfirmed + /// winner no block carries yet, so re-delivery cannot outrank the + /// sweep's verdict. Cleared only by the sweep release pass, when a + /// later sweep proves the coin came free after all; a pre-stamp row + /// (written before holds named their winner) still frees on + /// re-delivery. + public var supersededByTxid: Data? + /// Position of this output within `spendingTransaction.input` /// (i.e. the canonical "vin index"). Captured at the moment the /// spend is reconciled — sourced from diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 6d6e80644a4..52365db3006 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -88,6 +88,19 @@ public final class PersistentWallet { /// ChainLock has ever been observed for this wallet (fresh /// wallet, or pre-feature row). public var lastAppliedChainLockBytes: Data? + /// NUMERIC block height of the wallet's last applied ChainLock — + /// the same watermark whose bincode blob sits in + /// `lastAppliedChainLockBytes`, which is opaque on this side of the + /// FFI. Delivered separately through the persistence extension's + /// `on_persist_wallet_changeset_chain_lock_height_fn` and stored + /// with monotonic-max semantics (chain locks only move forward). + /// This is one half of the swept-tombstone collection boundary + /// `min(chainlockHeight, syncedHeight)` — see + /// `PersistentPendingInput.winnerMinedHeight`. `nil` (fresh wallet, + /// pre-feature row, or a native library too old to fill the slot) + /// means no finality boundary is known and no tombstone may be + /// collected. Optional, so existing stores lightweight-migrate. + public var lastAppliedChainLockHeight: UInt32? /// User imported this wallet from an existing mnemonic (as /// opposed to generating a fresh one). Cosmetic flag that /// drives the "📥 Imported" badge; defaulted to `false` for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 8b7025e72b3..838eb567950 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -69,6 +69,26 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// across restarts. Mirrors /// `PersistenceCapabilities::TRACKED_MASTERNODES`. public static let trackedMasternodes: UInt64 = 1 << 10 + /// A round's sweep batches — delivered through the persistence + /// extension's size-negotiated sweep callback — are durably applied + /// batch by batch and in order: swept transactions and their outputs + /// are excluded from every restore and enumeration path (physical + /// deletion or a durable marker alike), released outpoints are freed + /// unless a surviving claim supersedes, and non-released spend claims + /// are retained durably. Mirrors + /// `PersistenceCapabilities::CORE_SWEEP_REMOVAL`; Rust only honours + /// the declaration when the extension actually carries the callback. + public static let coreSweepRemoval: UInt64 = 1 << 11 + /// DashPay payment rows delivered on a store round + /// (`dashpay_payments_overlay`) are durably applied. This is what the + /// wallet-event adapter keys on before coupling a sweep's + /// `Pending → Failed` payment flip to the sweep's own atomic round — + /// a non-attesting host (Android keeps payment recording + /// in-memory-only) gets the in-memory flip with nothing + /// round-coupled. Mirrors `PersistenceCapabilities::DASHPAY_PAYMENTS`; + /// Rust only honours the declaration when the payments callback is + /// actually wired. + public static let dashpayPayments: UInt64 = 1 << 12 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4c0baa95899..a7eb1bcca25 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -79,6 +79,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: Data, transaction: PersistentTransaction ) -> Bool { + // A globally-swept row is never "owned" for restore purposes, even + // though `involvedAccounts` below can still name this wallet — that + // membership was recorded before the transaction lost the sweep and + // `applySweptTransaction` does not (and should not) rewrite history + // by removing it. Excluding here, at the single call site every + // restore-to-Rust enumeration goes through (`walletCoreTxids`), is + // what keeps a row `isGloballySwept` has already proven dead from + // being handed back as this wallet's transaction after a restart. + guard !transaction.isGloballySwept else { return false } if transaction.involvedAccounts.contains(where: { let wallet: PersistentWallet? = $0.wallet return wallet?.walletId == walletId @@ -146,6 +155,72 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// atomically. private var inChangeset = false + /// In-memory index over the rows the open changeset round has + /// inserted into `backgroundContext` but not yet saved, keyed by the + /// same columns the hot-path fetches filter on. + /// + /// Why it exists: a `FetchDescriptor` with the default + /// `includePendingChanges == true` evaluates its predicate IN MEMORY + /// against every unsaved insert of the target entity — + /// `Predicate.evaluate` walks the key path per row, with a dynamic + /// cast per step. The `#Index`/`.unique` declarations on the models + /// only accelerate the SQL half of the fetch; the pending-changes + /// half is always a linear scan. Because the whole round defers its + /// `save()` to `endChangeset` (the `inChangeset` contract above), a + /// large wallet's initial scan accumulates thousands of unsaved + /// inserts in one round, and every subsequent fetch paid O(inserts + /// so far) — quadratic over the round, and measured as ~99% of CPU + /// on `serialQueue` minutes after the SPV scan itself finished. + /// + /// How it is used: while the index is non-nil, the lookup helpers + /// (`fetchTransactionRow`, `fetchTxoRow`, `pendingInputRows`, + /// `coreAddressRow`) consult it first and run their store fetch with + /// `includePendingChanges = false`, so SQLite answers from its + /// indexes and never triggers the in-memory scan. The single-object + /// maps are READ-THROUGH: they hold both this round's unsaved + /// inserts (registered at the insert site) and every row a store + /// fetch has already resolved this round (registered by the helper). + /// Caching store hits is not an optimization — it is load-bearing + /// for correctness: a store-only fetch that matches an + /// already-registered object REFRESHES that object to its store + /// values, silently discarding the round's unsaved attribute + /// mutations (unlike the default pending-changes fetch, which + /// returns the object with its in-memory state; staged deletions do + /// survive the refresh). Registering every resolution means each + /// key touches the store at most once per round — at first touch, + /// before the round can have mutated the object — so the refresh + /// never has anything to discard. Both sources stay disjoint + /// because `beginChangeset` builds the index only over a clean + /// context. Rows deleted mid-round are filtered by `isDeleted` on + /// both sources (index entries are deliberately never + /// unregistered — `isDeleted` already answers the question, and it + /// also covers deletes on paths that don't know about the index, + /// e.g. wallet removal). + /// + /// Lifecycle: built by `beginChangeset`, discarded in + /// `endChangeset`'s `defer` on both the commit and rollback paths — + /// after a commit the cached rows are ordinary saved rows the store + /// fetch finds on its own, and on rollback the context un-inserts / + /// reverts every one of them, so the index dies with the round + /// either way and never leaks state across rounds. `nil` outside a + /// round (and inside a round that began on a dirty context — see + /// `beginChangeset`), in which case the lookup helpers run the + /// exact pre-index fetch, pending changes included. + private struct ChangesetRoundIndex { + var transactionsByTxid: [Data: PersistentTransaction] = [:] + var txosByOutpoint: [Data: PersistentTxo] = [:] + /// `PersistentPendingInput.outpoint` is deliberately not unique + /// (re-org / double-spend can stack rows on one outpoint — see + /// the model), so this holds only the round's staged inserts + /// per key; saved rows come from the store fetch each time. + /// Pending rows need no read-through registration because + /// nothing mutates their attributes before the sweep pass, and + /// sweeps run last in the round (see `pendingInputRows`). + var pendingInputsByOutpoint: [Data: [PersistentPendingInput]] = [:] + var coreAddressesByAddress: [String: PersistentCoreAddress] = [:] + } + private var roundIndex: ChangesetRoundIndex? + /// Breadcrumb backfills that arrived on the serial queue while a /// changeset round was open. The backfill both mutates /// `backgroundContext` and saves it, so running it mid-round would @@ -192,7 +267,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.network = network self.modelFetcher = modelFetcher self.backgroundContext = ModelContext(modelContainer) - self.backgroundContext.autosaveEnabled = true + // Autosave off: this context is the transaction buffer for the + // begin → changeset → sweeps → end sequence, and autosave can commit + // its pending mutations between those callbacks. Since sweeps moved + // to their own callback the round spans two calls, so an autosave + // landing in between would make the watermark and the additive rows + // durable while the removal is still unstaged — and `rollback()` + // cannot take back a save that already happened. The handler + // attests `ATOMIC_CHANGESETS`, which is what Rust now relies on to + // trust the split transport, so that guarantee has to be real. + // + // Nothing depends on the implicit commits: every path either runs + // inside a round, which `endChangeset` commits with its single + // `save()`, or saves itself when `inChangeset` is clear. + self.backgroundContext.autosaveEnabled = false self.trackedMasternodeContext = ModelContext(modelContainer) self.trackedMasternodeContext.autosaveEnabled = false } @@ -458,10 +546,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let existing = try? backgroundContext.fetch(descriptor).first { // Same terminal rule as the upsert guard above: a // Consumed (4) row is deliberately retained for - // historical lookup and the only removal emitter - // (`untrack_asset_lock`) targets rejected Built - // rows — a removal reaching a consumed row is by - // construction a stale write. + // historical lookup, and neither removal producer can + // legitimately name one — a Built row rejected at + // broadcast (`untrack_asset_lock`) never got that far, + // and a sweep of the funding transaction only + // tombstones entries still tracked, which a consumed + // lock no longer is. A removal reaching a consumed row + // is by construction a stale write. + // `AssetLockChangeSet::merge` guarantees one call never + // carries an upsert and a removal for the same + // outpoint, so the upserts-then-removals order above is + // layout, not load-bearing sequencing. if existing.statusRaw == 4 { continue } @@ -992,9 +1087,35 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Called from the Rust persister when an SPV round produces core- /// wallet state changes. Upserts PersistentAccount / Transaction / /// Utxo records so views observing via `@Query` update automatically. - func persistWalletChangeset(walletId: Data, changeset: UnsafePointer) { + /// + /// Returns `false` when the round could not be applied, which the C shim + /// forwards to Rust so `store()` rolls the round back instead of treating + /// it as durable. Everything this method itself applies is additive, so + /// only a failed wallet lookup reports it here; the round's subtractive + /// part arrives through `persistWalletChangesetSweeps` below, with its + /// own failure path. + @discardableResult + func persistWalletChangeset( + walletId: Data, + changeset: UnsafePointer + ) -> Bool { onQueue { - guard let wallet = findWalletRecord(walletId: walletId) else { return } + // A stale post-deletion callback is not a failure — there is + // simply nothing left to write to. A fetch that *throws* is a + // different matter: reporting success would let Rust discard the + // round's sweep, and a later callback could then persist a height + // beyond a removal that never landed. + let wallet: PersistentWallet? + do { + wallet = try fetchWalletRecord(walletId: walletId) + } catch { + print( + "⚠️ persistWalletChangeset: wallet lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + guard let wallet else { return true } let cs = changeset.pointee // Chain update. @@ -1024,6 +1145,31 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { wallet.lastUpdated = Date() } + // Bounded tombstone lifetime (the SwiftData mirror of the SQLite + // store's `collect_finalized_tombstones`): once the finality + // boundary reaches a swept tombstone's winner-height stamp, the + // row has provably never drained — a genuine claim's rows are + // deleted by the drain in `upsertUtxo` when its funding TXO + // lands — so what remains is junk from foreign inputs of swept + // incoming payments, previously permanent and attacker-growable. + // The boundary is upstream's verbatim: + // `min(chainlockHeight, syncedHeight)` — the chainlock half + // proves the winner's spend final, the synced half certifies + // BIP158 filter coverage of every block that could have carried + // the funding output. The chainlock height arrives NUMERICALLY + // through the extension's chain-lock-height slot (the bincode + // bytes above are opaque here); until one has been stored no + // finality boundary exists and nothing may be collected — + // present chainlock BYTES prove nothing about how far finality + // reaches, and synced-height progress alone is not finality. + if cs.has_chain, cs.chain.has_synced_height, cs.chain.synced_height > 0, + let clHeight = wallet.lastAppliedChainLockHeight { + collectFinalizedSweptTombstones( + walletId: walletId, + boundary: min(clHeight, cs.chain.synced_height) + ) + } + // Balance delta — Rust still emits per-round deltas, but the // PersistentWallet `balance*` fields they used to update were // removed (canonical source is now the in-memory account @@ -1042,10 +1188,823 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // Swept transactions no longer ride this struct: they arrive + // through `persistWalletChangesetSweeps(walletId:sweeps:count:)` + // below, fired by Rust immediately after this callback in the + // same round. The struct crosses the C ABI by bare pointer, so a + // field appended to it cannot be proven present to a consumer + // built after a producer — the extension callback's negotiated + // `struct_size` is what carries that proof instead. + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Delete this wallet's swept tombstones whose winner-height stamp the + /// finality boundary has reached: `winnerMinedHeight <= boundary`, + /// where the caller computes `boundary = min(chainlockHeight, + /// syncedHeight)` — upstream key-wallet's + /// `prune_finalized_observed_spends` condition verbatim, and the + /// SQLite store's `collect_finalized_tombstones`. No observation-age + /// margin: the stamp IS the winner's own mined height, carried on the + /// sweep event, so nothing here guesses when the winner mined. Rows + /// with no stamp are never collected: a mempool-context sweep + /// (IS-locked winner, unmined) deliberately writes its tombstone + /// unstamped, because such a winner has no mining deadline and no + /// watermark can prove its inputs' funding delivered-or-never — an + /// unstamped row is a live hold, resolved only by the funding TXO + /// draining it, a later block-context sweep stamping it, or a release + /// deleting it. See the property doc on + /// `PersistentPendingInput.winnerMinedHeight`. + /// + /// Housekeeping, not correctness: a pass that cannot run self-heals on + /// the next boundary-carrying round, so a fetch failure logs and + /// returns instead of failing the round the way the sweep path must. + private func collectFinalizedSweptTombstones(walletId: Data, boundary: UInt32) { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + // Same pending-changes + in-memory-filter pattern as the sweep + // path's tombstone scan: rows tombstoned earlier in this round + // exist only as staged state, and `isSweptTombstone` is mutable, so + // a store-side predicate on it would test stale saved values. + descriptor.includePendingChanges = true + let rows: [PersistentPendingInput] + do { + rows = try backgroundContext.fetch(descriptor) + } catch { + print( + "⚠️ collectFinalizedSweptTombstones: scan failed: " + + "\(error.localizedDescription); skipping this pass" + ) + return + } + for pending in rows where pending.isSweptTombstone && !pending.isDeleted { + // A nil stamp is deliberately NOT back-filled: no current + // writer produces one, and stamping it here would convert + // "no proof of finality" into a fabricated horizon. + guard let stamp = pending.winnerMinedHeight else { continue } + if stamp <= boundary { + backgroundContext.delete(pending) + } + } + } + + /// Extension entry for the round's NUMERIC chainlock height — the + /// same watermark whose bincode blob rides + /// `WalletChangeSetFFI.last_applied_chain_lock_bytes` (still stored, + /// for the Rust-side metadata roundtrip), delivered separately because + /// that blob is opaque here and the tombstone collection boundary + /// needs the number. Fired inside the round's begin/end bracket, after + /// the changeset callback, only when the round advanced the chainlock + /// watermark. + /// + /// Stores monotonic-max (chain locks only move forward; a late or + /// re-emitted lower height must not walk the boundary backwards), + /// then runs the tombstone collector with the completed boundary + /// `min(chainlockHeight, syncedHeight)` — the freshly known chainlock + /// half is what can newly prove a stamp final, so waiting for the next + /// height-carrying changeset would hold collectible junk for no + /// reason. Same fail-the-round contract as every per-kind callback: a + /// throwing wallet lookup returns `false` so Rust does not treat the + /// round as durable. + @discardableResult + func persistWalletChangesetChainLockHeight( + walletId: Data, + height: UInt32 + ) -> Bool { + onQueue { + let wallet: PersistentWallet? + do { + wallet = try fetchWalletRecord(walletId: walletId) + } catch { + print( + "⚠️ persistWalletChangesetChainLockHeight: wallet lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + guard let wallet else { return true } + + let effective = max(wallet.lastAppliedChainLockHeight ?? 0, height) + if wallet.lastAppliedChainLockHeight != effective { + wallet.lastAppliedChainLockHeight = effective + wallet.lastUpdated = Date() + } + + // `syncedHeight == 0` means no filter coverage is certified at + // all — the boundary's synced half is missing, so nothing can + // be proven final yet. + if wallet.syncedHeight > 0 { + collectFinalizedSweptTombstones( + walletId: walletId, + boundary: min(effective, wallet.syncedHeight) + ) + } + + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Apply a round's sweep batches — the one subtractive part of the + /// changeset path, delivered through the size-negotiated + /// `PersistenceCallbacksExtension` slot rather than as a field on + /// `WalletChangeSetFFI` (see `persistWalletChangeset` for why). Rust + /// fires this right after that callback within the same + /// begin/end round, so a wallet-relevant winner riding in the round has + /// its claim on the shared inputs already recorded when the removal here + /// decides which links are left pointing at a dead transaction. + /// + /// Returns `false` to fail the round, same contract as + /// `persistWalletChangeset`: a deletion that silently didn't happen + /// would have Rust clear the sweep while the dead row survives to be + /// replayed at the next load. + @discardableResult + func persistWalletChangesetSweeps( + walletId: Data, + sweeps: UnsafePointer?, + count: UInt + ) -> Bool { + onQueue { + // Same wallet gate as `persistWalletChangeset`: a stale + // post-deletion callback has nothing left to write to, but a + // lookup that throws must fail the round rather than let Rust + // discard a sweep that never landed. + let wallet: PersistentWallet? + do { + wallet = try fetchWalletRecord(walletId: walletId) + } catch { + print( + "⚠️ persistWalletChangesetSweeps: wallet lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + guard wallet != nil else { return true } + guard count > 0, let sweepsPtr = sweeps else { return true } + + // The funding txids this round removes, across every batch — + // the same changeset-wide set the SQLite co-swept rule keys + // on. A pending claim whose outpoint is funded by a co-swept + // loser is a claim on a dead parent's output — nobody's coin, + // not something the winner took: upstream's descendant closure + // always sweeps parent and child together, and its release + // computation excludes exactly these outpoints, so the claim + // is neither released nor legitimate to hold. Tombstoning it + // would wedge the parent's chainlocked reinstatement forever + // (the re-delivered funding output drains into the + // tombstone-outranks pick, `supersededByTxid` pins the hold, + // and the recovery clear refuses stamped rows). + var coSwept = Set() + for batchIndex in 0.. 0, let txidsPtr = batch.txids else { continue } + for i in 0..() + if batch.released_outpoints_count > 0, + let releasedPtr = batch.released_outpoints { + for i in 0.. 0, let txidsPtr = batch.txids { + // This wallet's detached tombstones, fetched ONCE per + // batch and grouped by the live `spendingTxid` each + // loser is looked up under. The per-loser form of this + // fetch paid the pending-changes tax — an in-memory + // predicate pass over every unsaved insert of the + // entity — once per swept txid, and a single + // network-derived sweep can carry many losers into the + // same round as thousands of freshly staged records. + // Pending changes stay ON (rows tombstoned earlier in + // this round exist only as staged state), the predicate + // names only the immutable `walletId`, and the mutable + // halves (`isSweptTombstone`, `spendingTxid`) are read + // off the live objects — a store-side predicate on a + // mutable column would test stale saved values. + // Rebuilt per batch, not per round: an earlier batch's + // retargets must be visible to a later batch sweeping + // that batch's winner. Within one batch no rebuild is + // needed — rows retarget to the batch's own winner, and + // upstream never lists a batch's winner among its own + // losers. + var tombstonesBySpender: [Data: [PersistentPendingInput]] = [:] + do { + var pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + pendingDescriptor.includePendingChanges = true + for pending in try backgroundContext.fetch(pendingDescriptor) + where pending.isSweptTombstone && !pending.isDeleted { + tombstonesBySpender[pending.spendingTxid, default: []] + .append(pending) + } + } catch { + print( + "⚠️ persistWalletChangesetSweeps: tombstone scan failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + + for i in 0.. 0, let claimedPtr = batch.claimed_inputs { + var claimed = Set() + for i in 0..( + predicate: #Predicate { claimed.contains($0.outpoint) } + ) + ) + pendingRows = try backgroundContext.fetch( + FetchDescriptor( + predicate: #Predicate { claimed.contains($0.outpoint) } + ) + ) + } catch { + print( + "⚠️ persistWalletChangesetSweeps: claimed-input lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + var unfunded = claimed + for txo in txoRows where !txo.isDeleted { + unfunded.remove(txo.outpoint) + guard Self.resolvedWalletId(of: txo) == walletId, + txo.spendingTransaction == nil + || txo.spendingTransaction?.txid == supersededBy + else { continue } + txo.isSpent = true + txo.supersededByTxid = supersededBy + txo.spendingInputIndex = nil + txo.lastUpdated = Date() + } + for outpoint in unfunded { + // Only an earlier tombstone for this winner counts + // as "already written". An ordinary pending row + // the winner's own record staged is a surviving + // spender's claim, left alone exactly like a + // linked TXO above; the tombstone is written + // beside it, the same end state a stored loser's + // sweep leaves, and the drain prefers the + // tombstone regardless of age. + let existing = pendingRows.first { + !$0.isDeleted && $0.outpoint == outpoint + && $0.walletId == walletId && $0.isSweptTombstone + && $0.spendingTxid == supersededBy + } + if let existing { + if let winnerMinedHeight { + existing.winnerMinedHeight = winnerMinedHeight + } + continue + } + // `inputIndex` is the input's position in its + // spending transaction, which the batch does not + // carry; the drain copies it onto the TXO for + // display order only. + let tombstone = PersistentPendingInput( + outpoint: outpoint, + inputIndex: 0, + spendingTxid: supersededBy, + spendingTransaction: nil, + walletId: walletId + ) + tombstone.isSweptTombstone = true + tombstone.winnerMinedHeight = winnerMinedHeight + backgroundContext.insert(tombstone) + roundIndex?.pendingInputsByOutpoint[outpoint, default: []] + .append(tombstone) + } + } + } + + // The released set applies by OUTPOINT, after every loser in + // the batch has been walked — `applySweptTransaction` only + // reaches the claims still attached to a loser's row, and a + // claim need not be. It can have drained into + // `PersistentTxo.supersededByTxid` already (the funding TXO + // arrived between the sweep that held the coin and this one + // freeing it), with the winner it names never recorded here + // — or its shared row already deleted by another wallet's + // callback — leaving no relationship for the loop above to + // follow. Kotlin's `releaseByOutpoint` and SQLite's + // outpoint-matched UPDATE both cover exactly this; without + // it the release is silently dropped and the coin stays + // spent forever. + // + // `spendingTransaction == nil` is the same guard as + // Kotlin's `spendingTxid IS NULL`: a coin some surviving + // transaction re-claimed in this round keeps that claim — + // only detached holds qualify. The wallet check mirrors the + // loop above: a released set is only ever true of the + // wallet that computed it. `supersededByTxid` clears with + // the hold it carried — a released coin keeping its dead + // winner's marker would turn the next hold on this outpoint + // permanent, because `upsertUtxo`'s recovery clear reads a + // present marker as a durable claim. + // + // Plain pending-changes fetch on purpose: this pass runs in + // the sweep phase, after relationship-driven mutations the + // round index cannot observe, and a store-only fetch here + // would refresh those away (see the fetch-helpers MARK). + // ONE fetch for the whole batch, keyed by the immutable + // outpoint set — the per-outpoint form paid the + // pending-changes tax (an in-memory pass over every unsaved + // TXO insert) once per released coin, and a release set is + // sized by a remote sender's transaction. + if !released.isEmpty { + let rows: [PersistentTxo] + do { + let releasedDescriptor = FetchDescriptor( + predicate: #Predicate { released.contains($0.outpoint) } + ) + rows = try backgroundContext.fetch(releasedDescriptor) + } catch { + // Same contract as the loser loop: a release + // silently skipped would report a removal durable + // that never fully happened. + print( + "⚠️ persistWalletChangesetSweeps: release lookup failed: " + + "\(error.localizedDescription); failing the round" + ) + return false + } + for txo in rows where !txo.isDeleted { + guard Self.resolvedWalletId(of: txo) == walletId, + txo.spendingTransaction == nil else { continue } + txo.isSpent = false + txo.supersededByTxid = nil + txo.spendingInputIndex = nil + txo.lastUpdated = Date() + } + } + } + + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Delete the mirror of a transaction the wallet swept. + /// + /// A swept transaction was a recorded spend that `supersededBy` provably + /// beat to one of its inputs, so it can never confirm; Rust has already + /// dropped it. Keeping the row would hand it back at the next load and + /// re-create a balance the wallet has already corrected — this is the + /// only removal the changeset path performs. + /// + /// `isGloballySwept` is upstream's word as of this callback, not a + /// permanent verdict — the wallet's sweep state can itself be swept in + /// turn (IS-lock precedence: a chainlocked return beats the IS-locked + /// conflict that swept it originally), and `upsertTransaction` clears + /// this flag when a later record reinstates the txid. See that + /// method's doc comment for what reinstatement can and cannot undo. + /// + /// `commit_batch` calls `store()` once per wallet, and each of those + /// commits independently — there is no single transaction spanning every + /// wallet this sweep touches. That splits what has to be durable in + /// *this* callback from what can wait for a later one: the outputs this + /// row created are phantom money for every wallet, not just the one + /// running right now, and once Rust has proven the row dead no + /// restore/enumeration path may serve it to anyone — waiting for the + /// last wallet's callback to confirm that would leave it acknowledged-but- + /// resurrectable for however long the other wallets take to run, or + /// forever if one of them crashes first or never arrives. So the outputs + /// are deleted and `isGloballySwept` is set in EVERY callback that + /// reaches this function, idempotently, before anything wallet-scoped is + /// touched below. Physically removing `row` itself is different: that is + /// safe to defer, because `isGloballySwept` already makes the row inert + /// the moment the first callback sets it — see the ownership check near + /// the bottom for why the row is still worth reclaiming once nothing + /// points at it, now purely as housekeeping. + /// + /// The coins it claimed to *spend* split in two, and + /// `released` is the authority on which is which: + /// + /// - an input named there came free — no surviving transaction spends it; + /// - every other input it claimed was taken by the transaction that beat + /// it, and is gone. + /// + /// That distinction cannot be made here. Upstream only ever sweeps + /// *unconfirmed* records, and this store flips `isSpent` only for a + /// spender that reached a block, so a swept loser holds its inputs by + /// link alone with `isSpent == false`; deleting the row nils the link and + /// every one of those coins would fall back into the restore set, + /// including the consumed one. Nor can the winner's own row be consulted: + /// it need not be wallet-relevant at all, and even when it is, the sweep + /// can be committed in a round that arrives before the winner's record. + /// So upstream computes the split and names the freed coins, and this + /// applies it verbatim — the rest are held spent with no spender + /// linked, attributed to the winner via `supersededByTxid`, which keeps + /// them out of the restore set durably. + /// + /// A held input can also have no `PersistentTxo` at all yet — the loser + /// was persisted before its own funding TXO was, so + /// `resolveInputOutpoint` parked the claim as a `PersistentPendingInput` + /// instead. `PersistentTransaction.pendingInputs` cascades on delete just + /// like `outputs`, so left alone that claim would vanish with `row` + /// below, and the funding TXO's own later `upsertUtxo` — even after a + /// restart — would have nothing to tell it the coin isn't really free. + /// A held pending input is therefore detached from `row` (so the cascade + /// no longer reaches it) and repointed at `supersededBy` before the + /// delete, flagged `isSweptTombstone` so `upsertUtxo` knows to keep the + /// coin spent — durably, via `PersistentTxo.supersededByTxid` — once the + /// funding TXO materializes rather than treating it as an ordinary + /// in-flight spend. A released pending input needs none of this: it is + /// left for the cascade, the same as a released materialized input needs + /// no special handling beyond the loop above. + /// + /// The tombstone is written for EVERY sweep context; only the stamp + /// differs. A BLOCK-CONTEXT sweep (`winnerMinedHeight` non-nil) stamps + /// the winner's own mined height — the projection of key-wallet's + /// `observed_spent_outpoints` — and `collectFinalizedSweptTombstones` + /// evicts the row once the finality boundary reaches it. A + /// mempool-context sweep (`winnerMinedHeight` nil — the winner is + /// IS-locked and not yet mined) writes the SAME tombstone UNSTAMPED, + /// which the collector never touches. The in-memory model an unstamped + /// tombstone mirrors is the account's `spent_outpoints`: upstream's + /// `drop_conflicted_transactions` deletes the loser and RETAINS the + /// winner's shared inputs there — under DIP-10 the IS lock alone + /// settles them — but that set is rebuilt from live records on load, + /// and after the sweep neither the deleted loser nor a (possibly + /// wallet-irrelevant) winner leaves a record to rebuild it from. The + /// tombstone is the hold's only durable carrier; dropping it lets a + /// post-restart funding delivery credit a coin the network has + /// provably consumed. + /// + /// Nothing may collect an unstamped tombstone: an IS-locked winner has + /// no mining deadline (and the funding tx of an input it spends may + /// itself be IS-locked and unmined), so no watermark proves the + /// funding delivered-or-never. It resolves only through proof — the + /// funding TXO drains it (a wallet-owned claim always eventually + /// delivers via BIP158), a later block-context sweep re-stamps it into + /// the collectible set, or a release deletes it. The permanent residue + /// is foreign inputs of IS-context sweeps (a swept INCOMING payment + /// reaches this loop too, and ownership cannot gate it — nothing + /// anywhere can prove an input foreign, dashpay/rust-dashcore#968), + /// bounded by attack cost rather than collection: masternodes lock + /// first-seen, so every such row needs a conflicting payment delivered + /// straight to this wallet while withheld from the network, plus a + /// fee-paying IS-locked double-spend. + /// + /// A tombstoned row can itself need to move again: `supersededBy` is + /// only this round's winner, and nothing stops it from losing a later + /// round to a further winner while its own funding TXO is still + /// unresolved. `row.pendingInputs` above cannot see that earlier + /// tombstone — it already detached from `spendingTransaction` (and + /// therefore from `row`) the moment it was first written — so it is + /// looked up the only other way it is still findable, by the scalar + /// `spendingTxid` it was repointed to, and carried the rest of the + /// chain below: deleted if this round finally frees its outpoint, + /// repointed at the new winner if not. + /// + /// `PersistentTransaction` is shared across wallets by design, but + /// `released` is not: upstream computes it per wallet + /// (`per_wallet_released_outpoints`), so this wallet's set says nothing + /// about an input a *different* wallet's coin claims on the same row. + /// The input decisions below are scoped to the inputs this wallet + /// actually owns; the physical row delete at the bottom is housekeeping + /// only now (see above) and runs once no other wallet's claim is still + /// attached to it. See the ownership check below for how "no other + /// wallet" is decided without an explicit cross-wallet coordination + /// point. + /// + /// Fetch-free by design: the caller resolves `row` (through the + /// round-index-aware sweep lookup, failing the round if SwiftData + /// cannot answer) and hands over this loser's `priorTombstones` from + /// its once-per-batch scan. A `nil` row skips only the row-scoped work, + /// NOT the whole function. Sweeps are idempotent and can name a + /// transaction this store never had — but they can also name one this + /// store DID have and another wallet's callback already deleted. The + /// row is shared; the detached tombstones this wallet wrote against it + /// are not, and they are exactly the state that is still findable — by + /// scalar `spendingTxid` — after the row is gone. Skipping them would + /// strand them: this wallet's release decision would never reach a + /// tombstone that then marks its coin spent by a transaction that no + /// longer exists, and a held one could never follow the chain to a + /// further winner. So the wallet-scoped tombstone reconciliation at the + /// bottom runs either way. + private func applySweptTransaction( + walletId: Data, + supersededBy: Data, + released: Set, + coSwept: Set, + row: PersistentTransaction?, + priorTombstones: [PersistentPendingInput], + winnerMinedHeight: UInt32? + ) { + if let row { + // The global half, done every time this function runs regardless + // of which wallet's callback it is or whether this row has been + // seen by a sweep before: delete the outputs this row created + // (they are nobody's coin, ever — a swept transaction cannot have + // funded anything) and mark the row excluded from restoration. + // Both are idempotent, so re-processing an already-flagged row (a + // second wallet's callback, or a re-emitted sweep) is a harmless + // no-op. + for output in row.outputs { + backgroundContext.delete(output) + } + row.isGloballySwept = true + + // `released` is only ever true of the wallet that computed it, so + // an input this wallet does not own must be left exactly as it is + // — that wallet's own callback (delivered earlier, arriving + // later, or never coming at all) is the only thing allowed to + // decide it. Resolved through `resolvedWalletId(of:)` rather than + // a raw `walletId` compare, same reasoning as `loadWalletList`: + // the denormalized column reads empty on a row migrated before it + // existed, and comparing it raw would make every such coin look + // unowned and leave it untouched forever. + for txo in row.inputs where Self.resolvedWalletId(of: txo) == walletId { + let held = !released.contains(txo.outpoint) + txo.isSpent = held + // A held coin is attributed to the winner — the same stamp + // the pending-input drain writes, and the one SQLite + // records as `spent_in_txid`. Without it the hold has no + // durable carrier: `upsertUtxo`'s recovery clear frees a + // spent row with neither a spender nor a marker, and a + // restore-rescan re-delivers the funding output precisely + // because it is blind to an unconfirmed winner no block + // carries yet — resurrecting a provably consumed coin. + // Only an explicit release frees a stamped hold; a + // released coin's stale marker is likewise the release + // pass's business (the outpoint loop in the caller), not + // this one's. + if held { txo.supersededByTxid = supersededBy } + txo.spendingTransaction = nil + txo.lastUpdated = Date() + } + for pending in row.pendingInputs where pending.walletId == walletId { + if coSwept.contains(pending.outpoint.prefix(32)) { + // A claim on a co-swept loser's own output: nobody's + // coin, never in `released`, and a tombstone here + // would outlive the parent's reinstatement — see the + // `coSwept` doc in the caller. Deleted with the batch, + // the mobile mirror of the SQLite co-swept DELETE. + backgroundContext.delete(pending) + continue + } + guard !released.contains(pending.outpoint) else { + // Deleted now rather than left for the row's cascade. + // Still attached it reads as this wallet's claim in the + // ownership check below, so a shared loser holding one + // released input per wallet deadlocks: each callback + // sees the other's row and declines the delete, and + // replaying either reaches the same stalemate. The + // global marker keeps the dead transaction from + // contributing funds regardless, but the row and both + // pending entries would otherwise be stored forever. + backgroundContext.delete(pending) + continue + } + // Held in every winner context — `CORE_SWEEP_REMOVAL` + // requires each non-released input to keep a durable + // spend claim before its funding TXO materializes. A + // block-context winner stamps its mined height; an + // IS-locked, unmined winner leaves the stamp nil and the + // collector never touches the row — see the doc comment + // above for what resolves an unstamped hold. + pending.spendingTransaction = nil + pending.spendingTxid = supersededBy + pending.isSweptTombstone = true + pending.winnerMinedHeight = winnerMinedHeight + } + + // Whatever is still attached to `row` after the scoping above + // belongs to a different wallet that has not weighed in yet — + // this wallet's own rows are all resolved by now, held ones + // detached and released ones deleted. Whichever callback finds nothing + // left over is the last one to run and performs the delete, so + // order stops mattering. A wallet whose callback never arrives at + // all just leaves the row behind with every other wallet's inputs + // already correctly decided — a leaked dead row, not a + // wrongly-spent coin, and a re-emitted sweep cleans it up. + // + // Nothing below is load-bearing for correctness anymore: `row` + // has no outputs and reads as `isGloballySwept` as of the block + // above, in every callback that reaches this point, regardless of + // whether this delete ever fires. This is reclaiming the + // now-inert row's storage, not finishing the sweep. Detached + // tombstones deliberately do not count as claims here — they no + // longer need the row (the scalar reconciliation below never + // touches it), so holding the delete for them would leak the row + // for nothing. Nor do this wallet's released pending inputs: + // they were deleted outright above precisely so they cannot + // stalemate another wallet's callback. + let otherWalletStillClaims = row.inputs.contains { txo in + txo.spendingTransaction != nil && Self.resolvedWalletId(of: txo) != walletId + } || row.pendingInputs.contains { pending in + pending.spendingTransaction != nil && pending.walletId != walletId + } + if !otherWalletStillClaims { + backgroundContext.delete(row) + } + } + + // Chained-sweep continuation: a pending row an EARLIER sweep already + // tombstoned to this loser (itself a sweep's winner until now) is no + // longer reachable through `row.pendingInputs` — see the doc comment + // above. The caller found it by the scalar `spendingTxid` it carries + // instead (its once-per-batch scan), scoped to this wallet for the + // same reason the live pending inputs above were: the tombstone + // names one specific wallet's coin, and only that wallet's own + // released set is the right authority to re-decide it. + // + // Deliberately runs even with `row` nil. A tombstone's very + // existence means `resolveInputOutpoint` declined to re-attach a + // pending row when the winner's own record arrived (the duplicate + // guard matches on `(outpoint, spendingTxid)` and a tombstone + // occupies that key), so a wallet-relevant winner can carry no + // attached claim of this wallet's at all — and another wallet's + // callback, seeing nothing attached, legitimately deletes the shared + // row before this wallet's callback ever runs. The tombstones are + // this wallet's private state; the row's fate says nothing about + // whether they still need their release applied or their chain + // continued. + for pending in priorTombstones where !pending.isDeleted { + if released.contains(pending.outpoint) || coSwept.contains(pending.outpoint.prefix(32)) + { + backgroundContext.delete(pending) + } else { + // Re-pointed to the new winner; the stamp moves ONLY when + // this sweep has a block context. A block-context re-point + // re-stamps to the NEW winner's mined height — the claim + // now belongs to a spend anchored at that block, and its + // collection horizon moves with it. A mempool-context + // re-point (`winnerMinedHeight` nil) keeps the existing + // stamp untouched: upstream never retracts a block-context + // observed-spend entry for an unconfirmed conflict, and + // collection at the retained height stays sound — the + // funding output of a spent outpoint is mined at or below + // the height of ANY block-context spender of it, so the + // boundary passing that height still proves the funding + // was delivered or never will be. + pending.spendingTxid = supersededBy + if let winnerMinedHeight { + pending.winnerMinedHeight = winnerMinedHeight + } + } } } + /// Sweep-phase transaction lookup: round-index first, store-only on a + /// miss, and the store hit is REGISTERED so the next lookup of the same + /// txid — a later batch of this round sweeping or chaining onto it — + /// returns the same object instead of re-fetching. That registration is + /// what makes the store-only miss path safe here: every transaction row + /// carrying staged state is already in the index (record upserts + /// register inserts and store hits, the drain registers + /// relationship-resolved winners, and this helper registers what it + /// fetches — covering `isGloballySwept` staged by an earlier batch), so + /// the refresh a store-only fetch performs can only land on a clean + /// row. The plain-fetch fallback with no active round keeps the old + /// behavior for unbracketed callers. + /// + /// This replaces a plain pending-changes fetch that paid an in-memory + /// predicate pass over every unsaved `PersistentTransaction` insert + /// once per swept txid — O(records × losers) in the folded rounds that + /// carry an initial scan's records and a large conflict sweep together, + /// all of it synchronous on the persistence queue before + /// `endChangeset`. + private func fetchSweepTransactionRow(txid: Data) throws -> PersistentTransaction? { + if let known = roundIndex?.transactionsByTxid[txid] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + descriptor.fetchLimit = 1 + descriptor.relationshipKeyPathsForPrefetching = [\.outputs, \.inputs, \.pendingInputs] + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = try backgroundContext.fetch(descriptor).first, !row.isDeleted else { + return nil + } + roundIndex?.transactionsByTxid[txid] = row + return row + } + /// Find or create the `PersistentWallet` row for `walletId`. /// Used only by `persistWalletMetadata`; every other write path /// fetches via `findWalletRecord` and drops on missing so that @@ -1065,10 +2024,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Find the `PersistentWallet` row for `walletId`. Returns `nil` /// when no row exists. private func findWalletRecord(walletId: Data) -> PersistentWallet? { + try? fetchWalletRecord(walletId: walletId) + } + + /// Throwing form of `findWalletRecord`, for callers that must tell a + /// successful "no such wallet" apart from a failed lookup — anything + /// carrying a subtractive change, where swallowing the failure would + /// report a removal durable that never happened. + private func fetchWalletRecord(walletId: Data) throws -> PersistentWallet? { let descriptor = FetchDescriptor( predicate: walletRecordPredicate(walletId: walletId) ) - return try? backgroundContext.fetch(descriptor).first + return try backgroundContext.fetch(descriptor).first } /// Predicate matching the `PersistentWallet` row owned by THIS @@ -1213,6 +2180,118 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // MARK: - Round-indexed lookups + // + // The helpers below are the only way the changeset hot path + // (`upsertTransaction`, `upsertUtxo`, `resolveInputOutpoint`, + // `markUtxoSpent`, `markUtxoInstantLocked`, `removePendingInputs`, + // `persistAccountAddresses`) resolves rows by key. Each one reads + // `roundIndex` first, and on a miss — only while the index is + // active — fetches with `includePendingChanges = false` so the store + // lookup stays on SQLite's indexes instead of scanning the round's + // pending inserts in memory (see `roundIndex`); a store hit is + // registered in the index so the same key never fetches twice in one + // round (the store-only refetch would refresh the object and discard + // the round's unsaved mutations — see `roundIndex`). A miss on both + // sources may re-fetch on a later call, which is safe: there is no + // registered object for the refresh to clobber. With no active index + // the helpers degrade to the plain default fetch. Predicates only + // name immutable key columns (`txid`, `outpoint`, `address` are + // fixed at insert), so matching on store values instead of in-memory + // values cannot miss an in-round mutation; mutable-column filters + // (`spendingTxid` on pending rows) stay in Swift at the call sites, + // on live values. `isDeleted` is filtered on both sources because a + // store-only fetch still returns rows whose delete is staged but + // unsaved. + // + // The sweep phase has its own fetch discipline. Loser rows resolve + // through `fetchSweepTransactionRow` — index-first, store-only on a + // miss, registering its hits so later batches reuse the object (see + // its doc for why the miss path cannot refresh staged state away). + // The per-batch tombstone scan and the by-outpoint release fetch stay + // on plain pending-changes fetches, ONCE per batch: they key on + // columns that MUTATE mid-round (`spendingTxid`, `isSweptTombstone`) + // or must see rows staged earlier in the round, which neither the + // index nor a store-only fetch can answer. The sweep pass also + // mutates TXO / pending rows through `row.inputs` / + // `row.pendingInputs` without any keyed lookup the index could + // observe — which is safe only because sweeps are applied LAST in + // `persistWalletChangeset`, so no store-only first-touch fetch can + // follow those mutations within the round and refresh them away. + + /// Resolve a `PersistentTransaction` by its unique `txid`. + private func fetchTransactionRow(txid: Data) -> PersistentTransaction? { + if let known = roundIndex?.transactionsByTxid[txid] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.transactionsByTxid[txid] = row + return row + } + + /// Resolve a `PersistentTxo` by its unique 36-byte `outpoint`. + private func fetchTxoRow(outpoint: Data) -> PersistentTxo? { + if let known = roundIndex?.txosByOutpoint[outpoint] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.txosByOutpoint[outpoint] = row + return row + } + + /// Every live `PersistentPendingInput` row keyed on `outpoint` — + /// saved rows plus this round's staged inserts. Non-unique key, so + /// this returns the full set; callers filter further (by + /// `spendingTxid`, `createdAt`) on the live objects. Saved rows are + /// re-fetched store-only on every call rather than registered: no + /// path mutates a pending row's attributes before the sweep pass, + /// and sweeps run last (see the MARK comment), so the refetch + /// refresh never has unsaved changes to discard — deletions, the + /// one staged state these rows do accumulate mid-round, survive it. + /// De-duped by object identity as insurance against a save landing + /// mid-round (which would make a staged row visible to the store + /// fetch too). + private func pendingInputRows(outpoint: Data) -> [PersistentPendingInput] { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + if roundIndex != nil { descriptor.includePendingChanges = false } + var rows = (try? backgroundContext.fetch(descriptor)) ?? [] + if let staged = roundIndex?.pendingInputsByOutpoint[outpoint] { + let seen = Set(rows.map { ObjectIdentifier($0) }) + rows.append(contentsOf: staged.filter { !seen.contains(ObjectIdentifier($0)) }) + } + return rows.filter { !$0.isDeleted } + } + + /// Resolve a `PersistentCoreAddress` by its unique `address`. + private func coreAddressRow(address: String) -> PersistentCoreAddress? { + if let known = roundIndex?.coreAddressesByAddress[address] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.address == address } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.coreAddressesByAddress[address] = row + return row + } + private func upsertTransaction(account: PersistentAccount, tx: TransactionRecordFFI) { // The `account` parameter scopes the wallet-id used for the // input-reconciliation pass at the bottom of this method, and @@ -1234,9 +2313,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // let resolvedWalletId: Data = account.wallet.walletId let txidData = hashData(tx.txid) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidData } - ) // The FFI projection always serializes the transaction body // (`dashcore::consensus::encode::serialize` upstream), so @@ -1258,8 +2334,43 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let firstSeen: UInt64 = tx.first_seen != 0 ? tx.first_seen : UInt64(Date().timeIntervalSince1970) + let existing = fetchTransactionRow(txid: txidData) + // A sweep is upstream's word at the moment it fired, but the + // wallet's sweep state is not monotonic: `CoreChangeSet::merge` + // documents the exact reachable sequence — an unconfirmed + // transaction swept by an IS-locked conflict can return + // chainlocked and sweep that conflict in turn, per key-wallet's + // own IS-lock precedence rules. When both events land in the same + // changeset the merge already strips the sweep before it gets + // here. Across separate rounds it can't: the earlier sweep is + // already durable (row tombstoned, possibly still physically + // present because another wallet's claim held the delete back — + // see `applySweptTransaction`), and this later record is the only + // signal this callback ever sees that the wallet reversed itself. + // Upstream never re-emits a live record for a txid it still + // considers dead, so a record naming an `isGloballySwept` txid is + // authoritative reinstatement, not a stale replay — treat it as + // upstream's newer word and let it win: clear the tombstone and + // fall through to the ordinary upsert below. + // + // What this does and does not restore: `context`/`blockHeight`, + // `involvedAccounts` membership, and this record's own input + // reconciliation all rebuild normally from here since they're + // driven straight off `tx` and `account`. The outputs + // `applySweptTransaction` physically deleted are a different + // story — they come back only if this round (or the one + // `upsertUtxo` processes moments later, before any other sweep + // callback can re-tombstone this row) also carries fresh + // `utxos_added` entries for them, the same way any transaction's + // outputs ordinarily arrive alongside its record. That is not + // this method's call to make: if Rust doesn't re-emit them, they + // cannot be reconstructed here from nothing. + if let existing, existing.isGloballySwept { + existing.isGloballySwept = false + } + let record: PersistentTransaction - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing { record = existing } else { record = PersistentTransaction( @@ -1273,6 +2384,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { firstSeen: firstSeen ) backgroundContext.insert(record) + roundIndex?.transactionsByTxid[txidData] = record } record.context = tx.context @@ -1376,6 +2488,46 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { tx.context >= TransactionContextType.inBlock.rawValue } + /// Whether a TXO's existing spender link must survive an arriving + /// record that also claims the outpoint. The link is this store's spend + /// attribution, and the sweep release pass trusts it: the loser walk + /// detaches rows by their spender and the by-outpoint release frees + /// only detached rows (`spendingTransaction == nil`). A network-final + /// spender's link must therefore never be stolen by a later conflicting + /// record — upstream prunes a chainlocked spender to a bare txid (and + /// after a restart holds no history at all), so a loser reusing that + /// coin arrives with upstream unable to see the settled claim, and its + /// own eventual sweep names the coin released. With the link intact the + /// release is refused; with it stolen, the provably consumed coin reads + /// unspent after the next restart — a guaranteed double spend. + /// + /// Kept when the existing spender has not been globally swept and is + /// either IS-locked or chainlocked. An ordinary in-block claim is left + /// to `reconcileSpendObservation`, whose reorg rule allows a different + /// in-block spender to replace it. The single sanctioned takeover here + /// mirrors DIP-10 precedence: a chainlocked arrival may take the coin + /// from a spender that was only IS-locked, while a plain in-block arrival + /// may not. A re-emit of the same spender is never a takeover. + private static func settledSpenderLinkIsKept( + existing: PersistentTransaction?, + newTxid: Data, + newContext: UInt32 + ) -> Bool { + guard let existing, existing.txid != newTxid else { return false } + guard !existing.isGloballySwept else { return false } + let existingIsInstantLocked = + existing.context == TransactionContextType.instantSend.rawValue + let existingIsChainLocked = + existing.context >= TransactionContextType.inChainLockedBlock.rawValue + guard existingIsInstantLocked || existingIsChainLocked else { + return false + } + let chainlockOverIsLock = + newContext >= TransactionContextType.inChainLockedBlock.rawValue + && existing.context == TransactionContextType.instantSend.rawValue + return !chainlockOverIsLock + } + /// Mark the `PersistentTxo` whose 36-byte `outpoint` matches the /// given input as spent and link it to `spendingTransaction`. /// If no matching TXO exists yet (in-Swift out-of-order, or @@ -1389,15 +2541,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { spendingTxid: Data, walletId: Data ) { - let txoDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - if let txo = try? backgroundContext.fetch(txoDescriptor).first { + if let txo = fetchTxoRow(outpoint: outpoint) { // Flag and link move together — see // `reconcileSpendObservation` for the finality rule. let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: txo.spendingTransaction?.txid, + currentSpender: txo.spendingTransaction, currentIsSpent: txo.isSpent, + supersededByTxid: txo.supersededByTxid, incoming: spendingTransaction, incomingTxid: spendingTxid ) @@ -1431,11 +2581,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // (outpoint, spending-tx) pair already exists — re-upserts // of the same transaction would otherwise produce // duplicate pending rows that all resolve to the same - // TXO, wasting fetch work on the resolve side. - let pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint && $0.spendingTxid == spendingTxid } - ) - if (try? backgroundContext.fetch(pendingDescriptor).first) == nil { + // TXO, wasting fetch work on the resolve side. The + // `spendingTxid` half of the pair is compared in Swift on + // the live rows (it is mutable — `applySweptTransaction` + // rewrites it on tombstones — so it can't be a store-side + // predicate under the round index's store-only fetch). + let alreadyPending = pendingInputRows(outpoint: outpoint) + .contains { $0.spendingTxid == spendingTxid } + if !alreadyPending { let pending = PersistentPendingInput( outpoint: outpoint, inputIndex: inputIndex, @@ -1444,6 +2597,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: walletId ) backgroundContext.insert(pending) + roundIndex?.pendingInputsByOutpoint[outpoint, default: []].append(pending) } } } @@ -1454,13 +2608,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `upsertUtxo`'s resolve path so a freshly-arrived TXO doesn't /// keep its corresponding pending row alive. private func removePendingInputs(for outpoint: Data) { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - guard let rows = try? backgroundContext.fetch(descriptor), !rows.isEmpty else { - return - } - for row in rows { + // Deletes are not unregistered from `roundIndex` — the stale + // entry answers `isDeleted == true` and every lookup filters on + // that (see the index's doc). + for row in pendingInputRows(outpoint: outpoint) { backgroundContext.delete(row) } } @@ -1473,11 +2624,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let txidData = hashData(utxo.outpoint.txid) let outpoint = PersistentTxo.makeOutpoint(txid: txidData, vout: utxo.outpoint.vout) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) let record: PersistentTxo - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing = fetchTxoRow(outpoint: outpoint) { record = existing // Backfill if the account or wallet linkage is missing — // the per-wallet query path filters on TXO.walletId, so @@ -1496,11 +2644,28 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // arrives. Note we no longer set `parentTx.account` — // transactions don't carry account linkage anymore (they // can span multiple accounts). - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidData } - ) let parentTx: PersistentTransaction - if let existingTx = try? backgroundContext.fetch(txDescriptor).first { + if let existingTx = fetchTransactionRow(txid: txidData) { + // A globally-swept parent is a transaction Rust has already + // proven can never confirm — a fresh UTXO entry naming its + // txid would (re-)create exactly the phantom output + // `applySweptTransaction` deletes on every callback that + // observes the sweep. Bail rather than attach a new + // `PersistentTxo` to a row still excluded from restoration. + // + // This does not fight `upsertTransaction`'s reinstatement + // path — it relies on it running first. `applyAccountChangeset` + // processes an account's `tx.transactions` before its + // `utxos_added`, so a reinstating record for this same txid + // in this same round has already cleared the tombstone by + // the time this guard reads it here; only a UTXO entry with + // no accompanying record this round (or in a stray one that + // arrives out of order relative to it) still finds the flag + // set. That is genuinely a stale/out-of-order signal — Rust + // does not otherwise re-emit a swept loser's own outputs — + // and staying defensive here is correct: there is no record + // in flight to attribute a resurrected output to. + guard !existingTx.isGloballySwept else { return } parentTx = existingTx } else { // Stub row — `transactionData` is left as empty @@ -1512,6 +2677,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // treats as miss. parentTx = PersistentTransaction(txid: txidData, transactionData: Data()) backgroundContext.insert(parentTx) + roundIndex?.transactionsByTxid[txidData] = parentTx } let script: Data = { @@ -1530,6 +2696,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.account = account record.walletId = resolvedWalletId backgroundContext.insert(record) + roundIndex?.txosByOutpoint[outpoint] = record } record.amount = utxo.amount @@ -1540,6 +2707,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.isLocked = utxo.is_locked record.lastUpdated = Date() + // The wallet is handing this outpoint over as a UTXO, so it holds it + // unspent — authoritative, and the only thing that can lift a mark + // with neither a spender nor a winner behind it (a pre-stamp row + // from before `applySweptTransaction` named its winner; every hold + // written today is stamped). A row whose spend is still on record + // is left alone: the pending-input resolve below owns that + // transition. So is a `supersededByTxid` hold: the winner that + // consumed this coin is known even though its row never + // materialized here, and a re-delivery cannot outrank that verdict + // — a restore-rescan re-finds the funding output precisely because + // it is blind to an unconfirmed winner no block carries yet. Only + // an explicit release frees a stamped coin. + if record.isSpent, record.spendingTransaction == nil, record.supersededByTxid == nil { + record.isSpent = false + } + // Attach the `PersistentCoreAddress` row, if we have one. The // address-emit pass typically runs ahead of the SPV-utxo pass // within a flush, so the row should exist; if it doesn't (TXO @@ -1547,11 +2730,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // leave the relationship nil — `record.address` stays as the // authoritative identifier. if record.coreAddress == nil, !record.address.isEmpty { - let addressLookup = record.address - let coreAddressDescriptor = FetchDescriptor( - predicate: #Predicate { $0.address == addressLookup } - ) - if let coreAddr = try? backgroundContext.fetch(coreAddressDescriptor).first { + if let coreAddr = coreAddressRow(address: record.address) { record.coreAddress = coreAddr } } @@ -1565,61 +2744,89 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `upsertTransaction`, so the spend signal is order- // independent at this layer regardless of which side arrives // first. - let outpointKey = record.outpoint - let pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpointKey } - ) - if let pendingRows = try? backgroundContext.fetch(pendingDescriptor), - !pendingRows.isEmpty { - // Reconcile EVERY deferred observation, not just the newest — - // the rows are about to be deleted, and picking one would let - // a mempool competitor recorded after a confirmed spender - // erase that confirmed evidence with the rows. Applying the - // finality-aware rule per row makes the order irrelevant by - // construction: confirmed evidence wins and is never - // displaced by a mempool observation, so the oldest-first - // pass below converges to the same state any order would. - var adoptedAny = false - for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) { - // Resolve the spending tx (prefer the relationship; fall - // back to a txid lookup if the row wasn't faulted in). + let pendingRows = pendingInputRows(outpoint: record.outpoint) + if !pendingRows.isEmpty { + // A tombstone outranks every ordinary row regardless of age. + // Newest-wins arbitrates between competing *observations*, but a + // tombstone is not an observation — it is the sweep's settled + // verdict that its winner consumed this coin. The two coexist in + // exactly one way: records precede sweeps within a round, so the + // winner's own record can stage an ordinary pending row for this + // outpoint moments before the sweep repoints the loser's row — + // which keeps its original, older `createdAt`. Letting the + // younger ordinary row win there would take the gated branch + // below (`isSpent` false until the winner confirms), never stamp + // `supersededByTxid`, and then delete every row including the + // tombstone — the durable hold evaporates and the consumed coin + // re-enters the restore set. + if let tombstone = pendingRows.filter(\.isSweptTombstone) + .max(by: { $0.createdAt < $1.createdAt }) { let resolvedSpending: PersistentTransaction? - if let spending = pending.spendingTransaction { + if let spending = tombstone.spendingTransaction { resolvedSpending = spending + // Resolved through the relationship, not the index — + // register it so a later lookup returns this same object + // without refreshing away its staged mutations. + roundIndex?.transactionsByTxid[spending.txid] = spending } else { - let spendingTxid = pending.spendingTxid - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } - ) - resolvedSpending = try? backgroundContext.fetch(txDescriptor).first + resolvedSpending = fetchTransactionRow(txid: tombstone.spendingTxid) } - guard let spending = resolvedSpending else { continue } - // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. - let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: record.spendingTransaction?.txid, - currentIsSpent: record.isSpent, - incoming: spending, - incomingTxid: spending.txid - ) - record.isSpent = verdict.isSpent - if verdict.adoptLink { - if record.spendingTransaction?.txid != spending.txid { - record.spendingTransaction = spending + + record.spendingInputIndex = tombstone.inputIndex + if let spending = resolvedSpending, + record.spendingTransaction?.txid != spending.txid { + record.spendingTransaction = spending + } + // `applySweptTransaction` repointed this row at the sweep's + // winner because the loser it originally recorded is gone. + // A sweep's winner is already final — there is no mempool + // state to wait out — so `isSpent` does not gate on + // `resolvedSpending` the way an ordinary pending spend does; + // that lookup only succeeds when the winner happens to have + // its own materialized row, which is not guaranteed. + // `supersededByTxid` is what makes the mark durable either + // way — it is what the recovery clear above checks so this + // coin isn't handed back as spendable on a later sync. + record.isSpent = true + record.supersededByTxid = tombstone.spendingTxid + } else { + // Reconcile every ordinary deferred observation before the + // rows are deleted. A later mempool competitor must not erase + // confirmed evidence, and a reorg of the linked spender must + // still be able to demote it. + var resolvedAny = false + for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) { + let resolvedSpending: PersistentTransaction? + if let spending = pending.spendingTransaction { + resolvedSpending = spending + roundIndex?.transactionsByTxid[spending.txid] = spending + } else { + resolvedSpending = fetchTransactionRow(txid: pending.spendingTxid) + } + guard let spending = resolvedSpending else { continue } + resolvedAny = true + let verdict = Self.reconcileSpendObservation( + currentSpender: record.spendingTransaction, + currentIsSpent: record.isSpent, + supersededByTxid: record.supersededByTxid, + incoming: spending, + incomingTxid: spending.txid + ) + record.isSpent = verdict.isSpent + if verdict.adoptLink { + if record.spendingTransaction?.txid != spending.txid { + record.spendingTransaction = spending + } + record.spendingInputIndex = pending.inputIndex } - // The vin index rides with the adopted claim so the - // spending tx's detail view renders inputs in the - // canonical serialized order. - record.spendingInputIndex = pending.inputIndex - adoptedAny = true } - } - if !adoptedAny, let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { - // No row resolved a spending tx this flush: carry the - // newest claim's vin index forward the way the old - // single-row path did; the linkage itself catches up on - // the next flush that carries the spending tx. - record.spendingInputIndex = newest.inputIndex + if !resolvedAny, + let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { + // No row resolved a spending tx this flush: carry the + // newest claim's vin index forward so linkage can catch + // up when that transaction arrives. + record.spendingInputIndex = newest.inputIndex + } } record.lastUpdated = Date() for row in pendingRows { @@ -1629,10 +2836,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } /// The one rule every spend-linkage writer follows, so `isSpent` and - /// `spendingTransaction` move as a single finality-aware state instead - /// of a monotonic flag beside a last-writer-wins link (which could - /// diverge: a mempool competitor replacing a confirmed link under a - /// stuck-true flag, or a reorg demotion never lowering it). + /// `spendingTransaction` move as a single finality-aware state. /// /// - Re-observation of the LINKED spender follows its context in both /// directions: a demotion is chain truth — key-wallet emits @@ -1642,16 +2846,32 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// claim is chain-attested and mutually exclusive with the old one. /// - A mempool competitor never displaces confirmed evidence: link and /// flag both stay. + /// - A stamped sweep hold is changed only by the explicit sweep release + /// path; an ordinary observation cannot reopen the consumed coin. + /// - A network-final spender link follows the conflict-finality ordering + /// in `settledSpenderLinkIsKept` rather than last-writer-wins. /// - When nothing confirmed is at stake, the newest observation wins /// the link and the flag stays down. private static func reconcileSpendObservation( - currentSpenderTxid: Data?, + currentSpender: PersistentTransaction?, currentIsSpent: Bool, + supersededByTxid: Data?, incoming: PersistentTransaction, incomingTxid: Data ) -> (adoptLink: Bool, isSpent: Bool) { + if let winnerTxid = supersededByTxid { + return (adoptLink: winnerTxid == incomingTxid, isSpent: true) + } + if settledSpenderLinkIsKept( + existing: currentSpender, + newTxid: incomingTxid, + newContext: incoming.context + ) { + let existingIsInBlock = currentSpender.map(spendIsInBlock) ?? false + return (adoptLink: false, isSpent: currentIsSpent || existingIsInBlock) + } let incomingInBlock = spendIsInBlock(incoming) - if currentSpenderTxid == incomingTxid { + if currentSpender?.txid == incomingTxid { return (adoptLink: true, isSpent: incomingInBlock) } if incomingInBlock { @@ -1668,10 +2888,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { txid: hashData(entry.outpoint.txid), vout: entry.outpoint.vout ) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - guard let txo = try? backgroundContext.fetch(descriptor).first else { + guard let txo = fetchTxoRow(outpoint: outpoint) else { return } // Link the spending transaction. The FFI now carries @@ -1689,10 +2906,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if txo.spendingTransaction?.txid == spendingTxid { spendingTx = txo.spendingTransaction } else { - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } - ) - spendingTx = try? backgroundContext.fetch(txDescriptor).first + spendingTx = fetchTransactionRow(txid: spendingTxid) } } // When the spending tx isn't resolved this flush, leave the row @@ -1704,8 +2918,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // Flag and link move together — see // `reconcileSpendObservation` for the finality rule. let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: txo.spendingTransaction?.txid, + currentSpender: txo.spendingTransaction, currentIsSpent: txo.isSpent, + supersededByTxid: txo.supersededByTxid, incoming: spending, incomingTxid: spendingTxid ) @@ -1728,10 +2943,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func markUtxoInstantLocked(_ op: OutPointFFI) { let outpoint = PersistentTxo.makeOutpoint(txid: hashData(op.txid), vout: op.vout) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - if let txo = try? backgroundContext.fetch(descriptor).first { + if let txo = fetchTxoRow(outpoint: outpoint) { txo.isInstantLocked = true txo.lastUpdated = Date() } @@ -1763,6 +2975,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks | PlatformWalletPersistenceCapabilities.trackedMasternodes + | PlatformWalletPersistenceCapabilities.coreSweepRemoval + | PlatformWalletPersistenceCapabilities.dashpayPayments ) } @@ -1778,6 +2992,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_persist_tracked_masternodes_fn = persistTrackedMasternodesCallback extensionCallbacks.on_load_tracked_masternodes_fn = loadTrackedMasternodesCallback extensionCallbacks.on_load_tracked_masternodes_free_fn = loadTrackedMasternodesFreeCallback + // Sweeps negotiate through this size-tagged structure rather than + // riding `WalletChangeSetFFI` because that struct crosses by bare + // pointer: `struct_size` above is what proves to an older native + // library that this slot exists, and proves to this build that an + // older library will simply never call it — rather than either side + // reading memory the other never allocated. + extensionCallbacks.on_persist_wallet_changeset_sweeps_fn = + persistWalletChangesetSweepsCallback + // The numeric chainlock height rides its own slot for the same + // reason: the bincode chainlock bytes on `WalletChangeSetFFI` are + // opaque to this side, and the tombstone-collection finality + // boundary `min(chainlockHeight, syncedHeight)` needs the number. + extensionCallbacks.on_persist_wallet_changeset_chain_lock_height_fn = + persistWalletChangesetChainLockHeightCallback return extensionCallbacks } @@ -1853,13 +3081,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `persistAccountChangeset`, …) fires between begin and end and /// only mutates `backgroundContext`; `save()` happens at the end. /// - /// Currently a no-op beyond the tag — `ModelContext`'s pending- - /// change buffer already gives us the batching we need. Kept as - /// a named hook so future work (explicit transaction scoping, - /// instrumented timing, etc.) has an obvious seam. + /// Beyond the tag, this builds the round's insert index (see + /// `roundIndex`) — `ModelContext`'s pending-change buffer already + /// gives us the batching we need. func beginChangeset(walletId: Data) { onQueue { self.inChangeset = true + // The index's O(1) lookups are only equivalent to the plain + // pending-changes fetch when the index and the store + // partition the rows between them: index = this round's + // inserts, store = everything saved. A context that is + // already dirty here (an out-of-round writer whose `save()` + // threw and left its staged rows behind) breaks that + // partition — such a row is in neither source — so the + // round runs unindexed and the lookup helpers fall back to + // the exact pre-index fetch, pending changes included. + self.roundIndex = backgroundContext.hasChanges ? nil : ChangesetRoundIndex() SDKLogger.event( "persistence_changeset_started", category: .persistence, @@ -1890,8 +3127,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // Clear the flag before draining deferred backfills so each one's // save() lands cleanly outside the round; `drainDeferredBackfills` // is guarded on `!inChangeset`, so the ordering inside this `defer` - // (clear, then drain) is load-bearing. + // (clear, then drain) is load-bearing. The round index dies here + // on both paths — after the commit its entries are ordinary saved + // rows the store fetch finds on its own, and after a rollback the + // context has un-inserted every one of them. defer { + self.roundIndex = nil self.inChangeset = false self.drainDeferredBackfills() } @@ -3138,7 +4379,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // No save here even outside a round: the Rust store() round // that invoked this callback brackets it with begin/end, so // `inChangeset` is set in practice; if a host ever fires it - // without a bracket, autosave/next round flushes the stage. + // without a bracket, the next round's own save flushes the + // stage (autosave is disabled on this context — see init). } } @@ -3575,12 +4817,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { for entry in entries { let address = entry.address - let existingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.address == address } - ) - let existing = try? backgroundContext.fetch(existingDescriptor).first let row: PersistentCoreAddress - if let existing = existing { + if let existing = coreAddressRow(address: address) { row = existing } else { row = PersistentCoreAddress( @@ -3594,6 +4832,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { balance: entry.balance ) backgroundContext.insert(row) + roundIndex?.coreAddressesByAddress[address] = row } // Mutation path for both insert + update. row.publicKey = entry.publicKey @@ -3616,12 +4855,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // address row now exists. Avoid the SwiftData // optional-relationship-in-predicate gotcha by // filtering nil-coreAddress in Swift after the fetch. + // + // Deliberately NOT a round-indexed store-only lookup: this + // joins TXOs by `address`, and the rows it returns are the + // same objects the outpoint-keyed hot path mutates — a + // store-only fetch here would refresh those objects and + // discard the round's unsaved writes (see `roundIndex`). + // The pending-changes scan this keeps is bounded by the + // round's TXO inserts per emitted address entry; the + // outpoint-keyed quadratic hot path stays indexed. let txoBackfillDescriptor = FetchDescriptor( predicate: #Predicate { $0.address == address } ) if let txosAtAddress = try? backgroundContext.fetch(txoBackfillDescriptor) { for txo in txosAtAddress where txo.coreAddress == nil { txo.coreAddress = row + // This write happened outside any keyed lookup, so + // register the row: a later first-touch + // `fetchTxoRow` for this outpoint would otherwise + // run a store-only fetch and refresh the link away + // (see `roundIndex`). + roundIndex?.txosByOutpoint[txo.outpoint] = txo } } } @@ -6167,6 +7421,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func recordEntry( for txRow: PersistentTransaction, accountIndex: UInt32 ) -> UnresolvedAssetLockTxRecordFFI? { + // A globally swept funding transaction lost a double spend on + // one of its inputs and cannot become a resolvable asset lock. + guard !txRow.isGloballySwept else { return nil } let txBytes = txRow.transactionData guard !txBytes.isEmpty else { return nil } let txBuf = UnsafeMutablePointer.allocate(capacity: txBytes.count) @@ -6272,9 +7529,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) -> (UnsafeMutablePointer?, Int) { // Provider special-tx kinds are the contiguous discriminant range // 2...5 (ProviderRegistration=2 … ProviderUpdateRevocation=5). + // `!isGloballySwept` excludes a provider tx that itself lost a + // double-spend on one of its inputs — an edge case (most losers are + // ordinary spends), but a swept row is never restorable regardless + // of kind. let descriptor = FetchDescriptor( predicate: #Predicate { tx in tx.transactionTypeKind >= 2 && tx.transactionTypeKind <= 5 + && tx.isGloballySwept == false } ) guard let providerTxs = try? backgroundContext.fetch(descriptor), @@ -6797,10 +8059,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Replace the stored tracked-masternode set for `networkRaw` with /// `rows` (whole-set semantics, mirroring the Rust trait contract). /// - /// Registry writes arrive OUTSIDE Rust `store()` rounds and use their own - /// context, so this method always saves before returning success. An - /// unrelated wallet changeset can therefore neither absorb nor roll back - /// a tracked-node mutation. + /// Registry writes arrive OUTSIDE Rust `store()` rounds, so this + /// method commits on its own dedicated `ModelContext` and never + /// touches the round-scoped `backgroundContext`. Sharing the round's + /// context (the pre-merge shape: stage, and let `endChangeset` + /// commit when a round is open) would let an unrelated round + /// failure — e.g. the DashPay deferred-payment owner check calling + /// `rollback()` — silently revert a registry write this method + /// already reported `Ok` to Rust for; an untracked masternode would + /// then resurrect at next load, and the Rust side's whole-set writes + /// mean nothing re-issues the removal until the next registry + /// mutation. A dedicated context makes the success return truthful: + /// `true` if and only if the whole-set replace saved durably. + /// Masternode rows are written by this callback alone, so the + /// separate transaction cannot race the round's own row set. func persistTrackedMasternodes(networkRaw: UInt32, rows: [TrackedMasternodeRow]) -> Bool { onQueue { do { @@ -7038,6 +8310,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { guard let row = try? backgroundContext.fetch(descriptor).first else { return nil } + // A globally-swept row can still physically exist (another + // wallet's claim may not have cleared yet), but Rust has already + // proven it dead — treat it the same as "no such transaction" + // rather than handing back a body sent-payment reconciliation or + // the asset-lock proof flow would read as live. + guard !row.isGloballySwept else { + return nil + } // The Rust side decodes `transactionData` into a // `dashcore::Transaction`; an empty buffer (left over // from an orphaned stub row in the UTXO upsert path @@ -7555,8 +8835,64 @@ private func persistWalletChangesetCallback( .takeUnretainedValue() let walletId = Data(bytes: walletIdPtr, count: 32) - handler.persistWalletChangeset(walletId: walletId, changeset: changesetPtr) - return 0 + // Non-zero fails the round: `endChangeset(success: false)` rolls the + // staged writes back and Rust keeps its in-memory state instead of + // treating a partly-applied changeset as durable. + return handler.persistWalletChangeset(walletId: walletId, changeset: changesetPtr) ? 0 : 1 +} + +/// C shim for the extension's `on_persist_wallet_changeset_sweeps_fn` — +/// the round's sweep batches, fired right after the changeset callback +/// above within the same begin/end bracket. Same non-zero-fails-the-round +/// contract: a removal Rust believes durable but that never landed would +/// replay the dead row at the next load. +private func persistWalletChangesetSweepsCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + sweepsPtr: UnsafePointer?, + sweepsCount: UInt +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetSweeps( + walletId: walletId, + sweeps: sweepsPtr, + count: sweepsCount + ) ? 0 : 1 +} + +/// C shim for the extension's +/// `on_persist_wallet_changeset_chain_lock_height_fn` — the round's +/// NUMERIC chainlock height, fired inside the same begin/end bracket +/// after the changeset callback whenever the round advanced the chainlock +/// watermark. Same non-zero-fails-the-round contract as its siblings. +private func persistWalletChangesetChainLockHeightCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + chainLockHeight: UInt32 +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetChainLockHeight( + walletId: walletId, + height: chainLockHeight + ) ? 0 : 1 } /// C shim for `on_changeset_begin_fn`. Forwards to diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 1e28edf34d8..1cb131f5c04 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -1,171 +1,238 @@ +import CoreData import Foundation import SwiftData import XCTest @testable import SwiftDashSDK +/// Migration coverage against stores that OLDER BUILDS actually wrote. +/// +/// The fixture stores under `Fixtures/SchemaStores/` were produced by the +/// SDK's own `DashSchemaV1` / `DashSchemaV2` / `DashSchemaV3` definitions at +/// commit 96a103375e (v4.2.0-dev.8), before the wallet transaction graph +/// gained its sweep columns. Each carries a wallet, an account, a core +/// address, two transactions, a TXO linked to both, a pending input, an +/// identity, a keyword, an asset lock and (from V2) a tracked masternode — +/// enough to exercise every relationship in the wallet graph. +/// +/// A source store written IN this process by `Schema(versionedSchema:)` +/// would not do: SwiftData binds an entity name to the first Swift type +/// that claims it, so such a store carries whatever shape the process had +/// already bound, and a frozen version whose entities had silently rebound +/// to the live shape would round-trip itself and pass vacuously. Only a +/// store from a build that knew nothing of the live shape can tell. final class DashModelMigrationTests: XCTestCase { - @MainActor - func testV1StoreMigratesToV2AndAcceptsTrackedMasternodes() throws { + private struct Fixture { + let name: String + let version: any VersionedSchema.Type + let hasTrackedMasternode: Bool + let assetLockRecipientIsExternal: Bool? + } + + private static let fixtures: [Fixture] = [ + Fixture( + name: "dash-v1", version: DashSchemaV1.self, + hasTrackedMasternode: false, assetLockRecipientIsExternal: nil), + Fixture( + name: "dash-v2", version: DashSchemaV2.self, + hasTrackedMasternode: true, assetLockRecipientIsExternal: nil), + Fixture( + name: "dash-v3", version: DashSchemaV3.self, + hasTrackedMasternode: true, assetLockRecipientIsExternal: true), + ] + + private static let walletId = Data(repeating: 0x31, count: 32) + private static let spendTxid = Data(repeating: 0x32, count: 32) + private static let fundingTxid = Data(repeating: 0x34, count: 32) + private static let identityId = Data(repeating: 0x35, count: 32) + + /// A private, writable copy of a fixture store. + private func copyFixture(_ fixture: Fixture) throws -> (URL, URL) { + let source = try XCTUnwrap( + Bundle.module.url( + forResource: fixture.name, withExtension: "store", + subdirectory: "Fixtures/SchemaStores"), + "missing fixture \(fixture.name).store") let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("dash.store") - - let v1Schema = Schema(versionedSchema: DashSchemaV1.self) - let v1Configuration = ModelConfiguration( - "DashMigrationTest", - schema: v1Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v1Container: ModelContainer? = try ModelContainer( - for: v1Schema, - configurations: [v1Configuration]) - v1Container?.mainContext.insert(PersistentKeyword( - keyword: "preserved", - contractId: "contract")) - try v1Container?.mainContext.save() - v1Container = nil - - let v2Schema = Schema(versionedSchema: DashSchemaV2.self) - let v2Configuration = ModelConfiguration( - "DashMigrationTest", - schema: v2Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - let migrated = try ModelContainer( - for: v2Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v2Configuration]) - - let keywords = try migrated.mainContext.fetch(FetchDescriptor()) - XCTAssertEqual(keywords.map(\.keyword), ["preserved"]) - - migrated.mainContext.insert(PersistentTrackedMasternode( - networkRaw: Network.testnet.rawValue, - proTxHash: Data(repeating: 7, count: 32), - label: "new in V2", - addedAt: 1, - snapshotJSON: "{}")) - try migrated.mainContext.save() - XCTAssertEqual( - try migrated.mainContext.fetchCount( - FetchDescriptor()), - 1) + let copy = directory.appendingPathComponent("\(fixture.name).store") + try FileManager.default.copyItem(at: source, to: copy) + return (directory, copy) } - /// Guards the freeze itself: `DashSchemaV1.PersistentAssetLock` only - /// keeps V1/V2 stores openable if SwiftData names its entity - /// "PersistentAssetLock" — i.e. from the UNQUALIFIED type name. If a - /// future SwiftData release qualified nested types instead, the frozen - /// copy would silently register a *different* entity and the V2 -> V3 - /// stage would become a drop+create rather than an add-column, so this - /// has to fail loudly rather than in the field. - func testFrozenAssetLockKeepsTheLiveEntityName() throws { - for schema in [ - Schema(versionedSchema: DashSchemaV1.self), - Schema(versionedSchema: DashSchemaV2.self), - Schema(versionedSchema: DashSchemaV3.self) - ] { - let names = schema.entities.map(\.name) - XCTAssertTrue( - names.contains("PersistentAssetLock"), - "expected an entity named PersistentAssetLock, got \(names.sorted())") + private static func storeHashes(at url: URL) throws -> (String, [String: Data]) { + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( + type: .sqlite, at: url) + let checksum = try XCTUnwrap( + metadata["NSStoreModelVersionChecksumKey"] as? String, + "store has no model checksum") + let hashes = try XCTUnwrap( + metadata["NSStoreModelVersionHashes"] as? [String: Data], + "store has no entity hashes") + return (checksum, hashes) + } + + /// Every fixture opens through `DashModelContainer.create`'s exact + /// order — live schema built first, then the migration plan — and its + /// rows come back through the live types with the relationships intact + /// and the new columns at their migration defaults. + @MainActor + func testStoresWrittenByOlderBuildsMigrateThroughTheContainerFactory() throws { + for fixture in Self.fixtures { + let (directory, url) = try copyFixture(fixture) + defer { try? FileManager.default.removeItem(at: directory) } + + let container: ModelContainer + do { + container = try DashModelContainer.create(url: url) + } catch { + XCTFail("\(fixture.name): migration failed to open: \(error)") + continue + } + let context = container.mainContext + + let wallets = try context.fetch(FetchDescriptor()) + XCTAssertEqual(wallets.map(\.walletId), [Self.walletId], fixture.name) + let wallet = try XCTUnwrap(wallets.first) + XCTAssertEqual(wallet.name, "fixture wallet", fixture.name) + XCTAssertEqual(wallet.syncedHeight, 120, fixture.name) + XCTAssertNil(wallet.lastAppliedChainLockHeight, fixture.name) + XCTAssertEqual(wallet.accounts.count, 1, fixture.name) + XCTAssertEqual(wallet.identities.map(\.identityId), [Self.identityId], fixture.name) + + let accounts = try context.fetch(FetchDescriptor()) + let account = try XCTUnwrap(accounts.first, fixture.name) + XCTAssertEqual(accounts.count, 1, fixture.name) + XCTAssertEqual(account.wallet.walletId, Self.walletId, fixture.name) + XCTAssertEqual(account.coreAddresses.map(\.address), ["yFixtureAddress"], fixture.name) + XCTAssertEqual( + Set(account.involvedTransactions.map(\.txid)), + [Self.spendTxid, Self.fundingTxid], fixture.name) + + let transactions = try context.fetch(FetchDescriptor()) + XCTAssertEqual(transactions.count, 2, fixture.name) + for transaction in transactions { + XCTAssertFalse(transaction.isGloballySwept, fixture.name) + } + let funding = try XCTUnwrap( + transactions.first { $0.txid == Self.fundingTxid }, fixture.name) + let spend = try XCTUnwrap( + transactions.first { $0.txid == Self.spendTxid }, fixture.name) + XCTAssertEqual(funding.outputs.count, 1, fixture.name) + XCTAssertEqual(spend.inputs.count, 1, fixture.name) + XCTAssertEqual(spend.pendingInputs.count, 1, fixture.name) + + let txos = try context.fetch(FetchDescriptor()) + XCTAssertEqual(txos.count, 1, fixture.name) + let txo = try XCTUnwrap(txos.first) + XCTAssertEqual(txo.amount, 1_000, fixture.name) + XCTAssertEqual(txo.transaction?.txid, Self.fundingTxid, fixture.name) + XCTAssertEqual(txo.spendingTransaction?.txid, Self.spendTxid, fixture.name) + XCTAssertEqual(txo.coreAddress?.address, "yFixtureAddress", fixture.name) + XCTAssertEqual(txo.account?.accountIndex, 0, fixture.name) + XCTAssertNil(txo.supersededByTxid, fixture.name) + + let pendingInputs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(pendingInputs.count, 1, fixture.name) + let pending = try XCTUnwrap(pendingInputs.first) + XCTAssertEqual(pending.spendingTxid, Self.spendTxid, fixture.name) + XCTAssertEqual(pending.spendingTransaction?.txid, Self.spendTxid, fixture.name) + XCTAssertFalse(pending.isSweptTombstone, fixture.name) + XCTAssertNil(pending.winnerMinedHeight, fixture.name) + + let identities = try context.fetch(FetchDescriptor()) + XCTAssertEqual(identities.map(\.identityId), [Self.identityId], fixture.name) + XCTAssertEqual(identities.first?.balance, 5, fixture.name) + XCTAssertEqual(identities.first?.wallet?.walletId, Self.walletId, fixture.name) + + let keywords = try context.fetch(FetchDescriptor()) + XCTAssertEqual(keywords.map(\.keyword), ["preserved"], fixture.name) + + let locks = try context.fetch(FetchDescriptor()) + XCTAssertEqual(locks.count, 1, fixture.name) + XCTAssertEqual(locks.first?.amountDuffs, 100_000, fixture.name) + XCTAssertEqual( + locks.first?.recipientIsExternal, fixture.assetLockRecipientIsExternal, + fixture.name) + + let tracked = try context.fetchCount(FetchDescriptor()) + XCTAssertEqual(tracked, fixture.hasTrackedMasternode ? 1 : 0, fixture.name) + + // The migrated store is writable through the new columns. + wallet.lastAppliedChainLockHeight = 130 + txo.supersededByTxid = Data(repeating: 0x36, count: 32) + try context.save() + XCTAssertEqual( + try context.fetch(FetchDescriptor()).first? + .lastAppliedChainLockHeight, + 130, fixture.name) } + } - // V2 and V3 differ ONLY in that one entity's shape, never in which - // entities exist — that is what makes the stage lightweight. - XCTAssertEqual( - Schema(versionedSchema: DashSchemaV2.self).entities.map(\.name).sorted(), - Schema(versionedSchema: DashSchemaV3.self).entities.map(\.name).sorted()) + /// Each frozen version, built AFTER the live schema (the order + /// `DashModelContainer.create` uses), still hashes every entity exactly + /// as the build that shipped it did. This is the property the previous + /// partial freeze lacked: a frozen wallet whose live `PersistentAccount` + /// pointed back at the live wallet type rebound "PersistentWallet" to the + /// live shape and moved V1–V3's checksums the moment the live schema + /// was built. + func testFrozenVersionsBuiltAfterTheLiveSchemaHashLikeTheStoresTheyShipped() throws { + _ = DashModelContainer.schema + + for fixture in Self.fixtures { + let (directory, url) = try copyFixture(fixture) + defer { try? FileManager.default.removeItem(at: directory) } + let (shippedChecksum, shippedHashes) = try Self.storeHashes(at: url) + + let scratch = directory.appendingPathComponent("scratch.store") + let schema = Schema(versionedSchema: fixture.version) + let configuration = ModelConfiguration( + "DashSchemaScratch", schema: schema, url: scratch, allowsSave: true, + cloudKitDatabase: .none) + _ = try ModelContainer(for: schema, configurations: [configuration]) + let (builtChecksum, builtHashes) = try Self.storeHashes(at: scratch) + + let drifted = shippedHashes.keys.filter { shippedHashes[$0] != builtHashes[$0] } + .sorted() + XCTAssertEqual( + drifted, [], + "\(fixture.name): entities whose frozen shape no longer matches the shipped store") + XCTAssertEqual( + Set(builtHashes.keys), Set(shippedHashes.keys), + "\(fixture.name): entity membership differs from the shipped store") + XCTAssertEqual(builtChecksum, shippedChecksum, "\(fixture.name): checksum") + } + } + + /// Guards the frozen models' entity identity. SwiftData must derive each + /// nested type's entity name from the unqualified type name so migration + /// stages add columns to existing tables instead of dropping and creating + /// unrelated entities. + func testFrozenModelsKeepTheLiveEntityNames() throws { + let live = Set(Schema(versionedSchema: DashSchemaV4.self).entities.map(\.name)) + for version in [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self] + as [any VersionedSchema.Type] + { + let frozen = Set(Schema(versionedSchema: version).entities.map(\.name)) + XCTAssertTrue( + frozen.isSubset(of: live), + "\(version): frozen entities not known to the live schema: " + + "\(frozen.subtracting(live).sorted())") + } - // V1 -> V2 remains exactly "add PersistentTrackedMasternode". + // Successive versions alter shapes, not entity membership, except + // for the one addition V2 makes. XCTAssertEqual( Set(Schema(versionedSchema: DashSchemaV2.self).entities.map(\.name)) .subtracting(Schema(versionedSchema: DashSchemaV1.self).entities.map(\.name)), ["PersistentTrackedMasternode"]) - } - - /// The regression this whole freeze exists for: a store written by the - /// schema-V2 definition of `PersistentAssetLock` (no `recipientIsExternal`) - /// must still open once the live model has grown that property. - /// - /// The source store is created from `DashSchemaV2`, which references the - /// frozen `DashSchemaV1.PersistentAssetLock` — not the live type — so - /// this exercises the real cross-version path rather than trivially - /// round-tripping today's model. - @MainActor - func testV2AssetLockStoreMigratesToV3AndBackfillsRecipientIsExternal() throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("dash.store") - - let outPointHex = String(repeating: "ab", count: 32) + ":0" - let walletId = Data(repeating: 3, count: 32) - - let v2Schema = Schema(versionedSchema: DashSchemaV2.self) - let v2Configuration = ModelConfiguration( - "DashAssetLockMigrationTest", - schema: v2Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v2Container: ModelContainer? = try ModelContainer( - for: v2Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v2Configuration]) - let legacyRow = DashSchemaV1.PersistentAssetLock( - outPointHex: outPointHex, - walletId: walletId, - transactionBytes: Data([1, 2, 3]), - fundingTypeRaw: 4, - identityIndexRaw: -1, - accountIndexRaw: 0, - amountDuffs: 100_000, - statusRaw: 4) - legacyRow.recipientPlatformAddressHash = Data(repeating: 9, count: 20) - legacyRow.recipientPlatformAddressType = 0 - v2Container?.mainContext.insert(legacyRow) - try v2Container?.mainContext.save() - v2Container = nil - - // Reopen exactly the way `DashModelContainer.create` does. - let v3Schema = Schema(versionedSchema: DashSchemaV3.self) - let v3Configuration = ModelConfiguration( - "DashAssetLockMigrationTest", - schema: v3Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - let migrated = try ModelContainer( - for: v3Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v3Configuration]) - - let locks = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(locks.count, 1) - let lock = try XCTUnwrap(locks.first) - XCTAssertEqual(lock.outPointHex, outPointHex) - XCTAssertEqual(lock.walletId, walletId) - XCTAssertEqual(lock.recipientPlatformAddressHash, Data(repeating: 9, count: 20)) - XCTAssertEqual(lock.recipientPlatformAddressType, 0) - // Backfilled NULL — the documented "treat as own" signal. - XCTAssertNil(lock.recipientIsExternal) - - // And the new column is writable on the migrated row. - lock.recipientIsExternal = true - try migrated.mainContext.save() XCTAssertEqual( - try migrated.mainContext.fetch(FetchDescriptor()) - .first?.recipientIsExternal, - true) + Schema(versionedSchema: DashSchemaV2.self).entities.map(\.name).sorted(), + Schema(versionedSchema: DashSchemaV3.self).entities.map(\.name).sorted()) + XCTAssertEqual( + Schema(versionedSchema: DashSchemaV3.self).entities.map(\.name).sorted(), + Schema(versionedSchema: DashSchemaV4.self).entities.map(\.name).sorted()) } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v1.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v1.store new file mode 100644 index 00000000000..48ff9ee6a67 Binary files /dev/null and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v1.store differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store new file mode 100644 index 00000000000..6ce7443421e Binary files /dev/null and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store new file mode 100644 index 00000000000..20d03939d0e Binary files /dev/null and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 478c114d0ca..4fd3c52c85c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -61,6 +61,12 @@ final class InvitationPersistenceTests: XCTestCase { // the persist/load/free trio onto `PersistentTrackedMasternode`, // so restart survival is genuinely attested. | PlatformWalletPersistenceCapabilities.trackedMasternodes + | PlatformWalletPersistenceCapabilities.coreSweepRemoval + // DashPay payment rows: the handler wires + // `on_persist_dashpay_payments_fn` and lands the overlay on + // `PersistentDashpayPayment` rows, so the sweep's Failed flip + // may ride this store's rounds — genuinely attested. + | PlatformWalletPersistenceCapabilities.dashpayPayments XCTAssertEqual( capabilities.version, @@ -83,6 +89,9 @@ final class InvitationPersistenceTests: XCTestCase { XCTAssertFalse(diagnostic.contains( PlatformWalletPersistenceCapabilities.pendingContactCrypto )) + XCTAssertTrue(diagnostic.contains( + PlatformWalletPersistenceCapabilities.coreSweepRemoval + )) } /// Create inserts one row (fields mapped, `walletId` set), a re-upsert of the @@ -95,7 +104,11 @@ final class InvitationPersistenceTests: XCTestCase { // 1. Create. handler.beginChangeset(walletId: walletId) - handler.persistInvitations(walletId: walletId, upserts: [snapshot(statusRaw: 0)], removed: []) + XCTAssertTrue( + handler.persistInvitations( + walletId: walletId, upserts: [snapshot(statusRaw: 0)], removed: [] + ) + ) _ = handler.endChangeset(walletId: walletId, success: true) var rows = try fetchRows(container) @@ -110,7 +123,11 @@ final class InvitationPersistenceTests: XCTestCase { // 2. Status change → upsert in place, no duplicate row. handler.beginChangeset(walletId: walletId) - handler.persistInvitations(walletId: walletId, upserts: [snapshot(statusRaw: 1)], removed: []) + XCTAssertTrue( + handler.persistInvitations( + walletId: walletId, upserts: [snapshot(statusRaw: 1)], removed: [] + ) + ) _ = handler.endChangeset(walletId: walletId, success: true) rows = try fetchRows(container) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift new file mode 100644 index 00000000000..71ac00f4b2e --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -0,0 +1,3102 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the one subtractive part of the changeset path: the sweep +/// batches delivered through the persistence extension's +/// `on_persist_wallet_changeset_sweeps_fn` alongside each round's +/// `WalletChangeSetFFI`. +/// +/// A swept transaction was a recorded spend that a later, final transaction +/// provably beat to one of its inputs, so it can never confirm and Rust has +/// already dropped it. Everything else the round carries is additive, so a +/// mirror that ignores the sweeps keeps the dead row, hands it back at the +/// next load, and re-creates a balance the wallet has already corrected — +/// the bug the upstream sweep exists to fix, one layer up. +/// +/// The fixtures model the shape that makes the coins tricky: an unconfirmed +/// loser — upstream sweeps nothing else — spends A and B, and the winner +/// takes only A. Because the loser never reached a block, this store never +/// flipped `isSpent` on either coin, so both are one deleted row away from +/// re-entering the restore set, and only the released set upstream carries +/// says which of them belongs there. +@MainActor +final class SweptTransactionPersistTests: XCTestCase { + + private let walletId = Data(repeating: 0x01, count: 32) + private let fundingTxid = Data(repeating: 0x41, count: 32) + private let sweptTxid = Data(repeating: 0x42, count: 32) + private let winnerTxid = Data(repeating: 0x44, count: 32) + + private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// File-backed variant of `makeHandler()` — an in-memory store can't + /// outlive its own `ModelContainer`, so simulating a restart (a fresh + /// load/persister over the same on-disk store) needs a real file two + /// separate containers can both point at. + private func makeHandler(url: URL) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let configuration = ModelConfiguration(schema: DashModelContainer.schema, url: url) + let container = try ModelContainer( + for: DashModelContainer.schema, + migrationPlan: DashMigrationPlan.self, + configurations: [configuration] + ) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// Seed the shape a confirmed spend leaves behind: a funding transaction + /// with two outputs, a spending transaction that claimed both (linked + /// and flagged spent), and the change that spend created. + /// + /// `winnerTakesA` models a wallet-relevant winner that already + /// re-pointed A at itself, which is what the additive half of the round + /// does before the sweep runs. + private func seedSpend(in container: ModelContainer, winnerTakesA: Bool) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 140_000 + ) + // Mempool context: the only kind of record upstream sweeps. + let swept = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -140_000 + ) + context.insert(funding) + context.insert(swept) + + let winner: PersistentTransaction? + if winnerTakesA { + let row = PersistentTransaction( + txid: winnerTxid, + transactionData: Data(repeating: 0x06, count: 10), + context: 2, + blockHeight: 102, + netAmount: -100_000 + ) + context.insert(row) + winner = row + } else { + winner = nil + } + + // A — the coin the winner also takes. When the winner is + // wallet-relevant its confirmed record owns the link and the flag; + // otherwise A is left where the unconfirmed loser put it, linked and + // unspent, which is what makes it indistinguishable from B. + let coinA = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + coinA.walletId = walletId + coinA.isSpent = winner != nil + coinA.spendingTransaction = winner ?? swept + context.insert(coinA) + + // B — named only by the loser, and so still unspent. + let coinB = PersistentTxo( + transaction: funding, + vout: 1, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinB.walletId = walletId + coinB.spendingTransaction = swept + context.insert(coinB) + + let change = PersistentTxo( + transaction: swept, + vout: 0, + amount: 60_000, + address: "yChangeAddr", + height: 0 + ) + change.walletId = walletId + context.insert(change) + + try context.save() + } + + /// Drive one changeset round of sweeps through the same entry point the + /// Rust persister calls. + /// One sweep batch: the transactions it removed, the winner it is + /// attributed to, the winner's finality context, and the coins it + /// freed. + private struct Batch { + var losers: [Data] + var winner: Data + /// The winner's own mined block height — `SweepBatchFFI`'s + /// `has_winner_mined_height`/`winner_mined_height` pair. Non-nil + /// models a block-context sweep (the winner is mined, tombstones + /// are written and stamped with this height); `nil` models a + /// mempool-context sweep (the winner is IS-locked and not yet + /// mined, and no tombstone may be created). Deliberately + /// undefaulted so every test states which world it is in. + var winnerMinedHeight: UInt32? + var released: [(txid: Data, vout: UInt32)] = [] + /// Inputs the batch vouches for on behalf of a loser this store + /// never held — `SweepBatchFFI.claimed_inputs`. + var claimedInputs: [(txid: Data, vout: UInt32)] = [] + } + + /// Drive a changeset of sweep batches through the same entry point the + /// Rust persister calls, preserving their order. + /// + /// The nested buffers are allocated explicitly and freed after the call. + /// `withUnsafeMutableBufferPointer` only guarantees its pointer for the + /// duration of its own closure, so storing `baseAddress` in a struct the + /// FFI reads later would hand the consumer a dangling pointer. + @discardableResult + private func sweep( + _ handler: PlatformWalletPersistenceHandler, + _ batches: [Batch] + ) -> Bool { + sweep(handler, batches, walletId: walletId) + } + + /// `walletId`-parameterized form for the multi-wallet tests below, + /// where the same shared loser row needs a separate callback per wallet + /// — each carrying that wallet's own `released` set, the way two real + /// `persistWalletChangeset` calls would. + @discardableResult + private func sweep( + _ handler: PlatformWalletPersistenceHandler, + _ batches: [Batch], + walletId: Data + ) -> Bool { + typealias RawTxid = ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) + + var txidBuffers: [UnsafeMutablePointer] = [] + var releasedBuffers: [UnsafeMutablePointer] = [] + var claimedBuffers: [UnsafeMutablePointer] = [] + var ffiBatches: [SweepBatchFFI] = [] + defer { + for (i, buf) in txidBuffers.enumerated() { + buf.deinitialize(count: batches[i].losers.count) + buf.deallocate() + } + for (i, buf) in releasedBuffers.enumerated() { + buf.deinitialize(count: batches[i].released.count) + buf.deallocate() + } + for (i, buf) in claimedBuffers.enumerated() { + buf.deinitialize(count: batches[i].claimedInputs.count) + buf.deallocate() + } + } + + for batch in batches { + let txids = UnsafeMutablePointer.allocate(capacity: max(batch.losers.count, 1)) + for (i, loser) in batch.losers.enumerated() { + var tuple: RawTxid = (0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0) + Swift.withUnsafeMutableBytes(of: &tuple) { dst in + loser.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + txids.advanced(by: i).initialize(to: tuple) + } + txidBuffers.append(txids) + + let freed = UnsafeMutablePointer.allocate( + capacity: max(batch.released.count, 1) + ) + for (i, outpoint) in batch.released.enumerated() { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = outpoint.vout + freed.advanced(by: i).initialize(to: entry) + } + releasedBuffers.append(freed) + + let claimed = UnsafeMutablePointer.allocate( + capacity: max(batch.claimedInputs.count, 1) + ) + for (i, outpoint) in batch.claimedInputs.enumerated() { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = outpoint.vout + claimed.advanced(by: i).initialize(to: entry) + } + claimedBuffers.append(claimed) + + var entry = SweepBatchFFI() + entry.txids = UnsafePointer(txids) + entry.txids_count = UInt(batch.losers.count) + entry.released_outpoints = UnsafePointer(freed) + entry.released_outpoints_count = UInt(batch.released.count) + entry.claimed_inputs = UnsafePointer(claimed) + entry.claimed_inputs_count = UInt(batch.claimedInputs.count) + Swift.withUnsafeMutableBytes(of: &entry.superseded_by) { dst in + batch.winner.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + // The winner's finality context: `has_winner_mined_height` + // false is the mempool path (IS-locked, unmined winner — + // no tombstone may be created), true carries the winner's + // own mined block. + entry.has_winner_mined_height = batch.winnerMinedHeight != nil + entry.winner_mined_height = batch.winnerMinedHeight ?? 0 + ffiBatches.append(entry) + } + + let sweeps = UnsafeMutablePointer.allocate( + capacity: max(ffiBatches.count, 1) + ) + sweeps.initialize(from: ffiBatches, count: ffiBatches.count) + defer { + sweeps.deinitialize(count: ffiBatches.count) + sweeps.deallocate() + } + + // The extension entry point, not a `WalletChangeSetFFI` field: the + // Rust persister delivers sweeps through the size-negotiated + // `on_persist_wallet_changeset_sweeps_fn` in the same round as the + // changeset callback, and this drives the Swift side of exactly + // that call. + handler.beginChangeset(walletId: walletId) + let applied = handler.persistWalletChangesetSweeps( + walletId: walletId, + sweeps: UnsafePointer(sweeps), + count: UInt(ffiBatches.count) + ) + _ = handler.endChangeset(walletId: walletId, success: applied) + return applied + } + + private func transaction(_ container: ModelContainer, txid: Data) -> PersistentTransaction? { + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + return try? context.fetch(descriptor).first + } + + private func txo(_ container: ModelContainer, txid: Data, vout: UInt32) -> PersistentTxo? { + let outpoint = PersistentTxo.makeOutpoint(txid: txid, vout: vout) + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try? context.fetch(descriptor).first + } + + /// The row and everything it created go; the funding transaction and its + /// coins stay. + func testSweptTransactionAndItsOutputsAreDeleted() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row is gone") + XCTAssertNil(txo(container, txid: sweptTxid, vout: 0), "the change it created is gone with it") + XCTAssertNotNil(transaction(container, txid: fundingTxid), "the funding transaction is untouched") + } + + /// The released set is applied verbatim: the coin it names comes back, + /// and the one it does not stays out — the winner took that one. + func testSweepFreesOnlyTheInputsTheWinnerDidNotTake() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + let takenByWinner = txo(container, txid: fundingTxid, vout: 0) + XCTAssertNotNil(takenByWinner) + XCTAssertTrue(takenByWinner!.isSpent, "the coin the winner took stays spent") + XCTAssertEqual(takenByWinner!.spendingTransaction?.txid, winnerTxid) + + let losersOwn = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(losersOwn) + XCTAssertFalse(losersOwn!.isSpent, "the loser's own input is free again") + XCTAssertNil(losersOwn!.spendingTransaction) + } + + /// The winner does not have to reach this store at all: it can spend our + /// coin while paying only to outside addresses, and then no record for it + /// is ever written here. Nothing on hand could separate the coin it took + /// from the loser's own — upstream can, and says so through the released + /// set, which is the entire reason that set is carried. + func testAnAbsentWinnerStillKeepsItsOwnInputSpent() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row still goes") + + let takenByWinner = txo(container, txid: fundingTxid, vout: 0) + XCTAssertNotNil(takenByWinner) + XCTAssertTrue( + takenByWinner!.isSpent, + "a coin the chain has already spent must not come back" + ) + XCTAssertNil(takenByWinner!.spendingTransaction, "and no spender is invented for it") + XCTAssertEqual( + takenByWinner!.supersededByTxid, + winnerTxid, + "the hold is attributed to the winner — SQLite's spent_in_txid, mirrored" + ) + + let losersOwn = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(losersOwn) + XCTAssertFalse( + losersOwn!.isSpent, + "the loser's own input is free, winner record or not" + ) + } + + /// A re-delivery of the funding output — what a restore-rescan does, + /// blind to the unconfirmed winner no block carries yet — must NOT + /// outrank the sweep's verdict: the coin was provably consumed, and + /// handing it back would resurrect it into the restore set on every + /// restore-from-seed until the winner confirms. Only an explicit + /// release frees a stamped hold — the same answer the SQLite store's + /// upsert valve gives to the identical event stream. + func testWalletReDeliveringAStampedHeldCoinKeepsItSpent() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + XCTAssertTrue(txo(container, txid: fundingTxid, vout: 1)!.isSpent) + + redeliverCoinB(handler) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(held.isSpent, "the stamped hold survives re-delivery") + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertNil(held.spendingTransaction) + } + + /// The winner's own record can reach this store only after the sweep + /// and the funding TXO already did — IS-locked, not yet in a block. + /// Both writers it flows through resolved the in-block gate to false + /// and wrote it outright: `resolveInputOutpoint` on the record pass, + /// then `markUtxoSpent` on the `utxos_spent` emit riding the same + /// round. Either flipped the durable stamped hold back into the + /// restore set until the winner confirmed — contradicting the verdict + /// the sweep already recorded (and the handler's own "winner is + /// already final" reasoning). + func testAWinnersLateRecordDoesNotDowngradeAStampedHold() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let l = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // The sweep holds the claim; the funding TXO then materializes it + // as a stamped hold. + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + + // The winner's own record finally arrives, IS-locked (context 1 < + // in-block), with the spent emit riding along the way a real round + // delivers both. + deliverRecordWithSpentEmit( + handler, + txid: winnerTxid, + context: 1, + inputOutpoint: (txid: fundingTxid, vout: 0) + ) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + held.isSpent, + "the winner's own unconfirmed arrival must not downgrade the stamped hold" + ) + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertEqual( + held.spendingTransaction?.txid, + winnerTxid, + "the spender is linked all the same" + ) + } + + /// The record-only half of the scenario above: a flush can deliver the + /// winner's record without a `utxos_spent` emit (the wallet had no live + /// UTXO to classify — the coin sits as a stamped hold), so + /// `resolveInputOutpoint`'s own monotonic guard must carry the hold by + /// itself. Pinned separately because the combined test's spent emit + /// re-applies the hold through `markUtxoSpent`'s guard, masking a + /// regression in the record pass alone. + func testAWinnersLateRecordAloneDoesNotDowngradeAStampedHold() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let l = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + + deliverRecordWithSpentEmit( + handler, + txid: winnerTxid, + context: 1, + inputOutpoint: (txid: fundingTxid, vout: 0), + includeSpentEmit: false + ) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + held.isSpent, + "the record pass alone must not downgrade the stamped hold" + ) + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertEqual(held.spendingTransaction?.txid, winnerTxid) + } + + /// One changeset round carrying a transaction record and — unless the + /// caller opts out to pin the record pass alone — the `utxos_spent` + /// emit for the input it consumed, the shape a real round takes when + /// the wallet classifies the spend in the same flush as the record. + private func deliverRecordWithSpentEmit( + _ handler: PlatformWalletPersistenceHandler, + txid: Data, + context: UInt32, + inputOutpoint: (txid: Data, vout: UInt32), + includeSpentEmit: Bool = true + ) { + let name = strdup("Standard { index: 0 }") + defer { free(name) } + + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + inputOutpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = inputOutpoint.vout + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = 0 + + var spent = SpentOutPointFFI() + spent.outpoint = input + Swift.withUnsafeMutableBytes(of: &spent.spending_txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &input) { inputPtr in + record.input_outpoints = inputPtr + record.input_outpoints_count = 1 + withUnsafeMutablePointer(to: &record) { recordPtr in + withUnsafeMutablePointer(to: &spent) { spentPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + if includeSpentEmit { + account.utxos_spent = spentPtr + account.utxos_spent_count = 1 + } + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Multi-input record delivery with no spent emit — the shape a + /// wallet-relevant loser takes when its inputs were never classified + /// against live UTXOs (`input_outpoints` carries every raw input either + /// way). + private func deliverRecord( + _ handler: PlatformWalletPersistenceHandler, + txid: Data, + context: UInt32, + inputOutpoints: [(txid: Data, vout: UInt32)] + ) { + let name = strdup("Standard { index: 0 }") + defer { free(name) } + + var inputs: [OutPointFFI] = inputOutpoints.map { outpoint in + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = outpoint.vout + return input + } + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = 0 + + handler.beginChangeset(walletId: walletId) + inputs.withUnsafeMutableBufferPointer { inputsPtr in + record.input_outpoints = inputsPtr.baseAddress + record.input_outpoints_count = UInt(inputsPtr.count) + withUnsafeMutablePointer(to: &record) { recordPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// The pruned-finalized-release defect, on this store's terms: a + /// chainlocked spender F is pruned upstream to a bare txid, so a later + /// loser L that pays this wallet while reusing F's input (plus an + /// attacker-owned one) sweeps with F's coin wrongly named in the + /// released set. F's row and its `spendingTransaction` link survive + /// HERE, and `settledSpenderLinkIsKept` keeps L's record pass from + /// stealing the attribution — so the loser walk never detaches F's coin + /// and the by-outpoint release refuses it (`spendingTransaction == nil` + /// gate), while the coin only L claimed still comes free in the same + /// batch. + func testAReleaseNamingACoinASettledSpenderStillClaimsIsRefused() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let finalizedTxid = Data(repeating: 0x46, count: 32) + let attackerTxid = Data(repeating: 0x47, count: 32) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 200_000 + ) + // F: the chainlocked spender of the settled coin — upstream keeps + // only its txid from here on; this store keeps the row and the link. + let finalized = PersistentTransaction( + txid: finalizedTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 3, + blockHeight: 120, + netAmount: -100_000 + ) + context.insert(funding) + context.insert(finalized) + + let settledCoin = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + settledCoin.walletId = walletId + settledCoin.isSpent = true + settledCoin.spendingTransaction = finalized + context.insert(settledCoin) + + let losersOwnCoin = PersistentTxo( + transaction: funding, + vout: 1, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + losersOwnCoin.walletId = walletId + context.insert(losersOwnCoin) + try context.save() + + // L: arrives after F's pruning — pays this wallet, reuses F's input + // alongside the attacker's and one coin of its own. Its record pass + // must NOT steal F's link. + deliverRecord( + handler, + txid: sweptTxid, + context: 0, + inputOutpoints: [ + (txid: fundingTxid, vout: 0), + (txid: attackerTxid, vout: 0), + (txid: fundingTxid, vout: 1), + ] + ) + XCTAssertEqual( + try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).spendingTransaction?.txid, + finalizedTxid, + "a settled spender's link is not stolen by a conflicting record" + ) + XCTAssertEqual( + try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)).spendingTransaction?.txid, + sweptTxid, + "the loser's own coin links normally" + ) + + // W (final) beats L on the attacker input alone. Upstream's release + // set — computed from live records that no longer include F — + // wrongly names F's coin alongside the loser's own. + sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1)] + )]) + + let settled = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + settled.isSpent, + "a released coin a settled stored spender still claims must stay spent" + ) + XCTAssertEqual(settled.spendingTransaction?.txid, finalizedTxid) + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent, "a coin only the swept loser claimed must come free") + XCTAssertNil(freed.spendingTransaction) + XCTAssertNil(freed.supersededByTxid) + } + + /// The backstop for rows written before holds named their winner: a + /// coin held spent with neither a spender nor a `supersededByTxid` + /// stamp has nothing durable behind it, so the wallet re-delivering it + /// as a UTXO — the authority on what it holds — still lifts the mark. + /// Every hold written today is stamped; this pins the migration path + /// for the ones already on disk. + func testAPreStampHoldStillFreesOnRedelivery() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 40_000 + ) + context.insert(funding) + let coinB = PersistentTxo( + transaction: funding, + vout: 1, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinB.walletId = walletId + coinB.isSpent = true + context.insert(coinB) + try context.save() + + redeliverCoinB(handler) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent, "a hold with nothing durable behind it frees on re-delivery") + XCTAssertNil(freed.spendingTransaction) + } + + /// Hand coin B back through the ordinary account changeset, the way a + /// rescan that re-finds the funding transaction does. + private func redeliverCoinB(_ handler: PlatformWalletPersistenceHandler) { + let name = strdup("Standard { index: 0 }") + let address = strdup("yFundAddr") + defer { + free(name) + free(address) + } + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = 1 + utxo.amount = 40_000 + utxo.address = address + utxo.height = 100 + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// A loser can be swept while its detection is still queued for + /// persistence: the projection then finds no live record, refuses to + /// restore the stale row, and this store never holds a transaction whose + /// inputs the sweep could walk. The detection's own input details ride + /// on the batch as claimed inputs and must be held under the winner — + /// durably, across a restart and a re-delivery of the funding output. + func testAClaimedInputOfALoserNeverHeldStaysSpentAcrossRestartAndRedelivery() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("claimed.store") + + let neverHeldLoser = Data(repeating: 0x62, count: 32) + let externalWinner = Data(repeating: 0x63, count: 32) + + do { + let (handler, container) = try makeHandler(url: storeURL) + // Only the funding side exists: two unspent coins and no loser row. + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 140_000 + ) + context.insert(funding) + for (vout, amount) in [(UInt32(0), UInt64(100_000)), (UInt32(1), UInt64(40_000))] { + let coin = PersistentTxo( + transaction: funding, + vout: vout, + amount: amount, + address: "yFundAddr", + height: 100 + ) + coin.walletId = walletId + context.insert(coin) + } + try context.save() + + // The round the adapter builds for a delayed detection folded + // with its sweep: no record, no utxos_spent, one batch vouching + // for coin B and releasing nothing. + handler.beginChangeset(walletId: walletId) + XCTAssertTrue(sweep(handler, [Batch( + losers: [neverHeldLoser], + winner: externalWinner, + winnerMinedHeight: 400, + claimedInputs: [(txid: fundingTxid, vout: 1)] + )])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + + XCTAssertNil( + transaction(container, txid: neverHeldLoser), + "no stale loser row is invented" + ) + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(held.isSpent, "the input the batch vouched for is held") + XCTAssertNil(held.spendingTransaction) + XCTAssertEqual(held.supersededByTxid, externalWinner) + let untouched = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(untouched.isSpent, "a coin the batch did not name is left alone") + } + + // Restart, then hand coin B back the way a rescan does. + let (handler, container) = try makeHandler(url: storeURL) + let restored = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(restored.isSpent, "the hold survives a restart") + redeliverCoinB(handler) + let redelivered = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(redelivered.isSpent, "re-delivery does not outrank the sweep's verdict") + XCTAssertEqual(redelivered.supersededByTxid, externalWinner) + } + + /// A winner's InstantSend record can link a materialized coin while + /// leaving it unspent until confirmation. A later claimed-input sweep of + /// a loser this store never held must preserve that valid winner link and + /// apply the durable hold rather than treating the link as a reason to + /// skip the claim. + func testAClaimedInputAlreadyLinkedToTheWinnerIsStillHeld() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 100_000 + ) + let winner = PersistentTransaction( + txid: winnerTxid, + transactionData: Data(repeating: 0x06, count: 10), + context: 1, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(funding) + context.insert(winner) + + let coin = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + coin.walletId = walletId + coin.spendingTransaction = winner + coin.isSpent = false + context.insert(coin) + try context.save() + + let absentLoser = Data(repeating: 0x45, count: 32) + XCTAssertTrue(sweep(handler, [Batch( + losers: [absentLoser], + winner: winnerTxid, + winnerMinedHeight: nil, + claimedInputs: [(txid: fundingTxid, vout: 0)] + )])) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(held.isSpent, "a valid winner link does not replace the durable hold") + XCTAssertEqual(held.spendingTransaction?.txid, winnerTxid) + XCTAssertEqual(held.supersededByTxid, winnerTxid) + } + + /// A loser can reach persistence before an InstantSend winner replaces + /// its input relationship. Because that sweep carries no claimed inputs, + /// the winner relationship itself must still lead to the durable hold. + func testPersistedLoserWinnerRelinkStaysHeldAcrossRestartAndReplay() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("winner-relink.store") + + do { + let (handler, container) = try makeHandler(url: storeURL) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + deliverRecordWithSpentEmit( + handler, + txid: sweptTxid, + context: 0, + inputOutpoint: (fundingTxid, 0) + ) + let loserLinked = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertEqual(loserLinked.spendingTransaction?.txid, sweptTxid) + XCTAssertFalse(loserLinked.isSpent) + + deliverRecordWithSpentEmit( + handler, + txid: winnerTxid, + context: 1, + inputOutpoint: (fundingTxid, 0) + ) + let winnerLinked = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertEqual(winnerLinked.spendingTransaction?.txid, winnerTxid) + XCTAssertFalse(winnerLinked.isSpent) + + XCTAssertTrue(sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: nil + )])) + XCTAssertNil(transaction(container, txid: sweptTxid)) + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + held.isSpent, + "a persisted loser's winner-linked input must stay held" + ) + XCTAssertEqual(held.spendingTransaction?.txid, winnerTxid) + XCTAssertEqual(held.spendingInputIndex, 0) + XCTAssertEqual(held.supersededByTxid, winnerTxid) + } + + let (handler, container) = try makeHandler(url: storeURL) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + let replayed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + replayed.isSpent, + "a restart and funding replay must not restore the winner-consumed coin" + ) + XCTAssertEqual(replayed.spendingTransaction?.txid, winnerTxid) + XCTAssertEqual(replayed.spendingInputIndex, 0) + XCTAssertEqual(replayed.supersededByTxid, winnerTxid) + } + + /// The same never-held loser, but the coin it spent has not been + /// classified as ours yet: the claim must survive as the same detached + /// tombstone a staged pending row turns into, so the funding TXO's + /// arrival drains into a held coin rather than a fresh unspent one. + func testAClaimedInputWithNoFundingRowLeavesAStampedTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + + let neverHeldLoser = Data(repeating: 0x62, count: 32) + let externalWinner = Data(repeating: 0x63, count: 32) + let claimedKey = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + + handler.beginChangeset(walletId: walletId) + XCTAssertTrue(sweep(handler, [Batch( + losers: [neverHeldLoser], + winner: externalWinner, + winnerMinedHeight: 400, + claimedInputs: [(txid: fundingTxid, vout: 1)] + )])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + + let tombstones = try ModelContext(container).fetch( + FetchDescriptor( + predicate: #Predicate { $0.outpoint == claimedKey } + ) + ) + XCTAssertEqual(tombstones.count, 1, "a placeholder claim is written for the unfunded input") + let tombstone = try XCTUnwrap(tombstones.first) + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertNil(tombstone.spendingTransaction) + XCTAssertEqual(tombstone.spendingTxid, externalWinner) + XCTAssertEqual(tombstone.winnerMinedHeight, 400) + + // Re-vouching for the same input (a re-emitted sweep) is idempotent. + handler.beginChangeset(walletId: walletId) + XCTAssertTrue(sweep(handler, [Batch( + losers: [neverHeldLoser], + winner: externalWinner, + winnerMinedHeight: 400, + claimedInputs: [(txid: fundingTxid, vout: 1)] + )])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + XCTAssertEqual( + try ModelContext(container).fetchCount( + FetchDescriptor( + predicate: #Predicate { $0.outpoint == claimedKey } + ) + ), + 1 + ) + + // The funding output arrives: it materialises already held. + redeliverCoinB(handler) + let drained = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(drained.isSpent, "the funding output arrives already held") + XCTAssertEqual(drained.supersededByTxid, externalWinner) + XCTAssertEqual( + try ModelContext(container).fetchCount( + FetchDescriptor( + predicate: #Predicate { $0.outpoint == claimedKey } + ) + ), + 0, + "the tombstone drains into the coin" + ) + } + + /// Two sweeps in one round, the later disagreeing with the earlier. + /// + /// The first frees coin B; a second transaction spends it; the second + /// sweep removes that spender and frees nothing, because its own winner + /// took B. The later answer is the true one — and it only sticks because + /// the batches are applied in sequence. Folding their release sets would + /// leave the first "B is free" outliving the last "B is spent". + func testALaterSweepKeepingACoinSpentOverridesAnEarlierRelease() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + // A second transaction takes coin B after the first sweep freed it. + let secondLoser = Data(repeating: 0x55, count: 32) + let context = ModelContext(container) + let reclaimer = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x07, count: 10), + context: 0, + blockHeight: 0, + netAmount: -40_000 + ) + context.insert(reclaimer) + let coinB = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == coinB } + ) + let row = try XCTUnwrap(try context.fetch(descriptor).first) + row.spendingTransaction = reclaimer + try context.save() + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]), + // Its winner consumed B, so this batch frees nothing. + Batch(losers: [secondLoser], winner: Data(repeating: 0x56, count: 32), winnerMinedHeight: 400), + ]) + + let contested = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(contested) + XCTAssertTrue( + contested!.isSpent, + "the later sweep kept the coin spent, so it must not come back" + ) + } + + /// Seed the review finding's exact shape: one loser transaction shared + /// by two wallets, spending a coin from each. `walletA` owns P, `walletB` + /// owns Q; neither wallet's `PersistentTransaction` row for the winner is + /// ever created here, matching the "winner can pay only outside + /// addresses" case the released set exists to handle. The two coins live + /// in the same funding transaction only for setup convenience — nothing + /// about the fix depends on that; what makes `loser` shared is that its + /// `row.inputs` spans two different owning wallets. + private func seedSharedLoserAcrossTwoWallets( + in container: ModelContainer, + walletA: Data, + walletB: Data, + loserTxid: Data + ) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletA, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 140_000 + ) + context.insert(funding) + + let loser = PersistentTransaction( + txid: loserTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -140_000 + ) + context.insert(loser) + + // P — wallet A's coin, claimed only by the shared loser. + let coinP = PersistentTxo( + transaction: funding, vout: 0, amount: 100_000, address: "yWalletA", height: 100 + ) + coinP.walletId = walletA + coinP.spendingTransaction = loser + context.insert(coinP) + + // Q — wallet B's coin, also claimed only by the shared loser. + let coinQ = PersistentTxo( + transaction: funding, vout: 1, amount: 40_000, address: "yWalletB", height: 100 + ) + coinQ.walletId = walletB + coinQ.spendingTransaction = loser + context.insert(coinQ) + + try context.save() + } + + /// The BLOCKING finding's exact shape, built on top of + /// `seedSharedLoserAcrossTwoWallets`: the shared loser also created an + /// output of its own — phantom money, since a transaction that never + /// confirms funded nothing — and was `involvedAccounts`-linked to an + /// account under `walletA` from back when it was still a live candidate + /// (the ordinary `upsertTransaction` path does this before a later round + /// ever learns the tx lost a double-spend). That link is what makes this + /// fixture actually exercise the fix: without the `isGloballySwept` + /// guard, `walletOwnsTransaction` finds `walletA` through + /// `involvedAccounts` alone, regardless of what happens to P. + private func seedSharedLoserWithOutputAndInvolvedAccount( + in container: ModelContainer, + walletA: Data, + walletB: Data, + loserTxid: Data + ) throws { + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletA, walletB: walletB, loserTxid: loserTxid + ) + let context = ModelContext(container) + let walletRecord = try XCTUnwrap( + try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.walletId == walletA }) + ).first + ) + let account = PersistentAccount( + wallet: walletRecord, accountType: 0, accountIndex: 0, accountTypeName: "Standard" + ) + context.insert(account) + + let loserDescriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == loserTxid } + ) + let loser = try XCTUnwrap(try context.fetch(loserDescriptor).first) + loser.involvedAccounts.append(account) + + let phantomChange = PersistentTxo( + transaction: loser, vout: 2, amount: 60_000, address: "yLoserChange", height: 0 + ) + phantomChange.walletId = walletA + context.insert(phantomChange) + + try context.save() + } + + /// The review finding, order 1: wallet B's callback — the one that + /// releases nothing — runs first. Before the fix this alone deleted the + /// shared loser row (nothing in the old code held it back), so wallet + /// A's later release of P landed on the missing-row no-op and P stayed + /// wrongly spent forever. + func testSharedLoserAppliesBothWalletsReleaseSetsRegardlessOfOrder_BThenA() throws { + let (handler, container) = try makeHandler() + let loserTxid = Data(repeating: 0x81, count: 32) + let winner = Data(repeating: 0x82, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Wallet B first: its own released set names nothing, so its coin + // (Q) is held rather than freed. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNotNil( + transaction(container, txid: loserTxid), + "wallet B alone must not delete a row wallet A still has a claim on" + ) + let untouchedP = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(untouchedP.isSpent, "wallet B's callback must not touch wallet A's coin") + XCTAssertNotNil(untouchedP.spendingTransaction, "P is still linked to the loser, untouched") + + // Wallet A second: its own released set names P. + sweep(handler, [ + Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + + XCTAssertNil( + transaction(container, txid: loserTxid), + "the last wallet to run performs the delete" + ) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(p.isSpent, "wallet A's own release must free its own coin") + XCTAssertNil(p.spendingTransaction) + + let q = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(q.isSpent, "wallet B's earlier decision to hold Q must survive wallet A's callback") + XCTAssertNil(q.spendingTransaction) + } + + /// The review finding, order 2: wallet A — the one that releases P — + /// runs first. The fix is meant to be order-independent, so this must + /// land on the exact same end state as the B-then-A ordering above. + func testSharedLoserAppliesBothWalletsReleaseSetsRegardlessOfOrder_AThenB() throws { + let (handler, container) = try makeHandler() + let loserTxid = Data(repeating: 0x91, count: 32) + let winner = Data(repeating: 0x92, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Wallet A first: releases P. + sweep(handler, [ + Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + + XCTAssertNotNil( + transaction(container, txid: loserTxid), + "wallet A alone must not delete a row wallet B still has a claim on" + ) + let untouchedQ = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(untouchedQ.isSpent, "wallet A's callback must not touch wallet B's coin") + XCTAssertNotNil(untouchedQ.spendingTransaction, "Q is still linked to the loser, untouched") + + // Wallet B second: releases nothing. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNil( + transaction(container, txid: loserTxid), + "the last wallet to run performs the delete" + ) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(p.isSpent, "wallet A's earlier release must survive wallet B's callback") + XCTAssertNil(p.spendingTransaction) + + let q = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(q.isSpent, "wallet B's own decision to hold its coin must stick") + XCTAssertNil(q.spendingTransaction) + } + + /// The BLOCKING review finding: a shared loser's own output, and its + /// reachability through `walletCoreTxids`, must not survive across a + /// restart when only ONE wallet's callback ever commits and the other's + /// never arrives at all — a crash, a rejection, or simply never coming. + /// + /// `commit_batch` calls `store()` once per wallet and each commits + /// independently, so before the fix wallet B alone could not delete a + /// row wallet A still had an outstanding claim on (see the + /// `_BThenA`/`_AThenB` tests above) — and the OUTPUT went with the row, + /// because deletion was the only thing that excluded either. If wallet + /// A's own callback then never runs, that hold is permanent: the row, + /// its phantom output, and its `involvedAccounts` link to wallet A all + /// stay fully live forever, so `walletCoreTxids` hands the dead + /// transaction back to wallet A as its own after every future restart. + /// + /// Only wallet B's callback ever runs here, and it releases nothing — + /// the worst case, since it gives the row no reason to be physically + /// deleted at all. The fix's global half must still make the output and + /// the enumeration exclusion durable from that single callback alone. + func testSharedLoserOutputAndEnumerationAreExcludedAfterOnlyOneWalletsCallbackCommits() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-shared-durability-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + let loserTxid = Data(repeating: 0xA1, count: 32) + let winner = Data(repeating: 0xA2, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + + do { + let (handler, container) = try makeHandler(url: storeURL) + try seedSharedLoserWithOutputAndInvolvedAccount( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Only wallet B's callback ever runs, and it releases nothing — + // wallet A's own callback (which would release P) never arrives + // in this test at all. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNotNil( + transaction(container, txid: loserTxid), + "wallet A's own claim on P is still outstanding, so the row itself survives" + ) + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "the loser's own output must not survive even a single committed callback, " + + "regardless of which wallet's callback that was" + ) + let row = try XCTUnwrap(transaction(container, txid: loserTxid)) + XCTAssertTrue( + row.isGloballySwept, + "any callback that reaches the sweep must flag the row, not just wallet A's own" + ) + } + + // Restart: a fresh handler/container over the same file. Wallet A's + // callback never happens in this test, simulating a crash or a + // rejection that stops it from ever arriving — the exact scenario + // the finding describes. + let (handler, container) = try makeHandler(url: storeURL) + + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "the phantom output must not resurrect across a restart" + ) + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertFalse( + txidsA.contains { $0.txid == loserTxid }, + "wallet A must not be able to enumerate the swept loser as its own transaction " + + "after a restart, even though it is still linked via involvedAccounts and " + + "its own callback never ran" + ) + } + + /// Cross-round reinstatement — the BLOCKING finding this round fixes. + /// The sweep and its reinstating record land in two SEPARATE + /// `persistWalletChangeset` rounds, with wallet B's still-outstanding + /// claim keeping the shared row physically present in between, exactly + /// as `testSharedLoserOutputAndEnumerationAreExcludedAfterOnlyOneWalletsCallbackCommits` + /// establishes on its own. Before the fix, `upsertTransaction` bailed + /// unconditionally on `isGloballySwept == true`, so round 2's record — + /// upstream's newer word, per `CoreChangeSet::merge`'s documented + /// IS-lock-precedence sequence (swept by an IS-locked conflict, then + /// returns chainlocked and sweeps that conflict in turn) — would be + /// silently discarded forever, and `upsertUtxo` would keep rejecting + /// its output on the strength of a tombstone nothing could ever clear. + /// Verified across a restart: the reinstatement has to be durable, not + /// merely visible in the context that just applied it. + func testAReinstatingRecordInALaterRoundRevivesASweptTransactionAndItsOutputs() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-reinstatement-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + let loserTxid = Data(repeating: 0xB1, count: 32) + let winner = Data(repeating: 0xB2, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + + do { + let (handler, container) = try makeHandler(url: storeURL) + try seedSharedLoserWithOutputAndInvolvedAccount( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Round 1: only wallet B's own sweep callback runs, releasing + // nothing. Wallet A's own claim on P (its funding coin) is still + // outstanding, so the shared row survives physically even + // though the global half of the sweep already tombstoned it and + // deleted its phantom output. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + let tombstoned = try XCTUnwrap(transaction(container, txid: loserTxid)) + XCTAssertTrue(tombstoned.isGloballySwept, "sanity: the row is tombstoned after round 1") + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "sanity: the loser's own output is gone after round 1" + ) + + // Round 2, a SEPARATE callback (not coalesced with round 1's + // sweep — the cross-round shape the merge-level fix in + // `CoreChangeSet::merge` cannot reach): the wallet returns + // chainlocked and sweeps the erstwhile winner in turn. Arrives + // here exactly like any freshly-detected transaction would — + // nothing marks it as "the reinstating one" — with its own + // output riding along in the same round the way a transaction's + // outputs ordinarily do. + deliverReinstatingRecord( + handler, + walletId: walletId, + txid: loserTxid, + context: 3, // inChainLockedBlock + blockHeight: 200, + inputOutpoints: [(txid: fundingTxid, vout: 0)], + outputVout: 2, + outputAmount: 60_000, + outputAddress: "yLoserChange" + ) + + let reinstated = try XCTUnwrap( + transaction(container, txid: loserTxid), + "the reinstating record must not be discarded" + ) + XCTAssertFalse( + reinstated.isGloballySwept, + "a later record naming a tombstoned txid must clear the tombstone" + ) + XCTAssertEqual(reinstated.blockHeight, 200) + + let revivedOutput = try XCTUnwrap( + txo(container, txid: loserTxid, vout: 2), + "the reinstated transaction's own output must come back" + ) + XCTAssertEqual(revivedOutput.amount, 60_000) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "wallet A reclaims its input once its own record is live again") + XCTAssertEqual(p.spendingTransaction?.txid, loserTxid) + + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertTrue( + txidsA.contains { $0.txid == loserTxid }, + "wallet A must be able to enumerate the reinstated transaction as its own again" + ) + } + + // Restart: a fresh handler/container over the same file. The + // reinstatement has to be durable, not just visible to the context + // that applied it. + let (handler, container) = try makeHandler(url: storeURL) + + let survived = try XCTUnwrap(transaction(container, txid: loserTxid)) + XCTAssertFalse(survived.isGloballySwept, "the reinstatement must survive a restart") + XCTAssertNotNil( + txo(container, txid: loserTxid, vout: 2), + "the revived output must survive a restart" + ) + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "the reclaimed input must survive a restart") + XCTAssertEqual(p.spendingTransaction?.txid, loserTxid) + + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertTrue( + txidsA.contains { $0.txid == loserTxid }, + "the reinstated transaction must still enumerate as wallet A's own after a restart" + ) + } + + /// A failed wallet lookup must fail the round, not read as "no such + /// wallet". + /// + /// `try?` collapsed the two: a thrown SwiftData fetch returned success + /// without applying the sweep, Rust discarded the subtractive event, and + /// a later round could then persist a height beyond a removal that never + /// landed. Driving the real failure is awkward, so this pins the + /// distinction that makes it impossible — a wallet that genuinely is not + /// there is still a successful no-op. + func testAMissingWalletIsASuccessfulNoOp() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + // Delete the wallet row, leaving the fetch to succeed and find + // nothing — the branch that must stay a success. + let context = ModelContext(container) + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try context.fetch(descriptor) { + context.delete(row) + } + try context.save() + + let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertTrue(applied, "a stale post-deletion callback is not a failure") + XCTAssertNotNil( + transaction(container, txid: sweptTxid), + "and it must not have applied anything either" + ) + } + + /// Companion to `testAMissingWalletIsASuccessfulNoOp` above, which its + /// own doc admits does not distinguish the fix from the old `try?` + /// behavior — a successful empty fetch reads identically either way. + /// This drives a genuinely THROWING fetch instead, using a real seam + /// rather than a mock: a file-backed store (so the container's SQLite + /// connection is live and long-lived, unlike the in-memory variant) is + /// truncated on disk, out from under that open connection, between + /// seeding and the sweep. `fetchWalletRecord`'s `context.fetch` then has + /// to perform real I/O against a file that is no longer a valid SQLite + /// database, which is the only way found to make it throw without + /// adding a test-only injection point to production code. + func testAThrowingWalletLookupFailsTheRound() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-throwing-lookup-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + let (handler, _) = try makeHandler(url: storeURL) + + // Corrupt the on-disk store out from under the still-open container + // BEFORE any context — including a seed helper's — reads or writes + // through it: SwiftData's row cache is scoped to the persistent + // store coordinator, not to any one `ModelContext`, so a row + // touched by a throwaway seeding context would still be served from + // that shared cache here and never reach disk at all. With nothing + // cached yet, `fetchWalletRecord`'s fetch is the first real read + // this store ever performs, and it hits the truncated file — well + // short of a valid SQLite header — directly. + let handle = try FileHandle(forWritingTo: storeURL) + handle.truncateFile(atOffset: 16) + try handle.close() + + let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertFalse(applied, "a genuinely failed wallet lookup must fail the round") + } + + /// Two wallets, each holding an unresolved *released* input on the same + /// shared loser — the case where the row would otherwise never be + /// reclaimed. + /// + /// Left attached, a released pending input reads as its wallet's claim + /// in the ownership check, so A declines the delete because B's row is + /// there and B declines because A's is: a stalemate no replay breaks. + /// The dead transaction contributes no funds either way thanks to the + /// global marker, so this is storage rather than balance — but the row + /// and both pending entries would be kept forever. + func testTwoWalletsReleasedPendingInputsDoNotDeadlockTheRowDelete() throws { + let (handler, container) = try makeHandler() + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: sweptTxid + ) + + // Each wallet has one pending input on the loser, and each will be + // released by its own wallet's sweep. + let context = ModelContext(container) + let loserTxid = sweptTxid + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == loserTxid } + ) + descriptor.fetchLimit = 1 + let loser = try XCTUnwrap(try context.fetch(descriptor).first) + let pendingA = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 8), + inputIndex: 0, + spendingTxid: loserTxid, + spendingTransaction: loser, + walletId: walletId + ) + let pendingB = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 9), + inputIndex: 1, + spendingTxid: loserTxid, + spendingTransaction: loser, + walletId: walletB + ) + context.insert(pendingA) + context.insert(pendingB) + try context.save() + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 8)]) + ]) + sweep( + handler, + [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 9)])], + walletId: walletB + ) + + XCTAssertNil( + transaction(container, txid: sweptTxid), + "a released pending input is not a claim once its own wallet has resolved it" + ) + } + + /// A txid the store has never seen is not an error: sweeps are + /// idempotent, and a round can name a transaction this mirror never + /// recorded in the first place. + func testSweepingAnUnknownTransactionIsANoOp() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + let applied = sweep(handler, [ + Batch(losers: [Data(repeating: 0x99, count: 32)], winner: winnerTxid, winnerMinedHeight: 400) + ]) + + XCTAssertTrue(applied, "an absent row is a successful no-op, not a failed round") + XCTAssertNotNil(transaction(container, txid: sweptTxid)) + XCTAssertNotNil(transaction(container, txid: fundingTxid)) + } + + /// The loser can be persisted before its own funding output ever is — + /// `upsertTransaction` parks a spend like that as a `PersistentPendingInput` + /// rather than a `PersistentTxo` update (see `resolveInputOutpoint`). + /// When the sweep holds that input (it's not in `released`), there is no + /// `PersistentTxo` row to mark — the only record of the claim is the + /// pending row, which cascades away with the loser it names unless + /// `applySweptTransaction` rescues it first. This is the regression the + /// review finding described: seed the pending spend, sweep it, restart + /// the store, and only then let the funding UTXO arrive. The coin must + /// come back spent, attributed to the winner, not as a fresh unspent row. + func testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-pending-input-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + do { + let (handler, container) = try makeHandler(url: storeURL) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let swept = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(swept) + // What `resolveInputOutpoint` would have written: the funding + // TXO for (fundingTxid, 0) has never been seen here. + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: swept, + walletId: walletId + )) + try context.save() + XCTAssertNil( + txo(container, txid: fundingTxid, vout: 0), + "sanity: the funding TXO has not arrived yet" + ) + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the loser is gone") + } + + // Restart: a fresh persister loading the same on-disk store. + let (handler, container) = try makeHandler(url: storeURL) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertTrue( + coin.isSpent, + "the winner's claim must survive the loser's deletion, a restart, " + + "and the funding UTXO's own arrival" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// Records precede sweeps within a round, so a wallet-relevant winner + /// whose own funding side is ALSO unobserved stages an ordinary pending + /// row for the same outpoint moments before the sweep repoints the + /// loser's row into a tombstone — and the tombstone keeps the loser's + /// original, older `createdAt`. The drain's newest-wins pick then + /// selected the winner's ordinary row, took the gated branch (`isSpent` + /// stays false until the winner confirms — never, for an IS-locked + /// unconfirmed winner), skipped the `supersededByTxid` stamp, and + /// deleted every pending row including the tombstone: the durable hold + /// evaporated and the consumed coin re-entered the restore set. + func testAWinnersOwnPendingRowDoesNotEvaporateTheSweepTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let outpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + + // The doomed spend arrived before its funding output — parked as a + // pending row, exactly what `resolveInputOutpoint` writes. Backdated + // so the winner's row below is strictly newer, as it always is in + // reality (the loser's record preceded the winner's by definition). + let loser = PersistentTransaction( + txid: sweptTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(loser) + let losersClaim = PersistentPendingInput( + outpoint: outpoint, + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: loser, + walletId: walletId + ) + losersClaim.createdAt = Date(timeIntervalSinceNow: -10) + context.insert(losersClaim) + + // The winner's own record — IS-locked, still unconfirmed — lands in + // the same round as the sweep, records first, and stages its own + // ordinary pending row for the same still-unfunded outpoint. + let winner = PersistentTransaction( + txid: winnerTxid, + transactionData: Data(repeating: 0x06, count: 10), + context: 1, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(winner) + context.insert(PersistentPendingInput( + outpoint: outpoint, + inputIndex: 0, + spendingTxid: winnerTxid, + spendingTransaction: winner, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + // Sanity: the coexisting pair this regression is about — the + // winner's ordinary row plus the repointed tombstone. + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + let rows = try context.fetch(pendingDescriptor) + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows.filter(\.isSweptTombstone).count, 1) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + coin.isSpent, + "the sweep's hold must survive the winner's own coexisting pending row" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// Chained-sweep continuation of `testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent` + /// above: L spends P; W spends P and Q and sweeps L, holding P (still + /// unfunded); X spends Q and sweeps W, this time releasing P. The + /// tombstone `applySweptTransaction` wrote for P when L was swept + /// already detached from `spendingTransaction`, so the second sweep of + /// W cannot find it through `row.pendingInputs` the way the first sweep + /// did — it can only be found by the scalar `spendingTxid` it now + /// carries. This is the review finding: without that second lookup, the + /// second sweep's release of P is silently dropped, and P's funding TXO + /// resurrects the coin attributed to the wrong (already deleted) + /// transaction instead of coming back spendable. + func testChainedSweepBeforeFundingReleasesAnEarlierTombstoneOnASecondSweep() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x61, count: 32) // L + let secondLoser = Data(repeating: 0x62, count: 32) // W + let finalWinner = Data(repeating: 0x63, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + // P (fundingTxid:0) has never been observed as a TXO — parked as a + // pending input, the same as `testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent`. + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding P (still unfunded). + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let tombstoneDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + let tombstone = try XCTUnwrap(try context.fetch(tombstoneDescriptor).first) + XCTAssertTrue(tombstone.isSweptTombstone, "the first sweep must tombstone the pending row") + XCTAssertEqual(tombstone.spendingTxid, secondLoser) + XCTAssertNil(tombstone.spendingTransaction, "must have detached from the doomed loser's FK") + + // W's own row, plus a materialized claim on Q, needed for the + // second sweep to find W at all — the same requirement any sweep of + // a wallet-relevant loser has. + let w = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x06, count: 10), + context: 0, + blockHeight: 0, + netAmount: -90_000 + ) + context.insert(w) + let qFunding = PersistentTransaction( + txid: Data(repeating: 0x65, count: 32), + transactionData: Data(repeating: 0x09, count: 10), + context: 2, + blockHeight: 100, + netAmount: 40_000 + ) + context.insert(qFunding) + let coinQ = PersistentTxo( + transaction: qFunding, + vout: 0, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinQ.walletId = walletId + coinQ.spendingTransaction = w + context.insert(coinQ) + try context.save() + + // Second sweep: X beats W, this time releasing P. + sweep(handler, [ + Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ]) + + let survivingTombstones = try context.fetch(tombstoneDescriptor) + XCTAssertTrue( + survivingTombstones.isEmpty, + "a released outpoint's tombstone must not survive a chained sweep" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertFalse( + coin.isSpent, + "the final sweep released this coin, so it must come back spendable even " + + "though an earlier sweep in the chain had tombstoned it" + ) + XCTAssertNil(coin.supersededByTxid) + } + + /// The held (not released) half of the chained scenario above: the + /// second sweep keeps P spent instead of releasing it, and the + /// tombstone must end up attributed to the NEW winner rather than the + /// intermediate one that no longer has a row. + func testChainedSweepBeforeFundingRepointsAnEarlierTombstoneToTheNewWinner() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x71, count: 32) // L + let secondLoser = Data(repeating: 0x72, count: 32) // W + let finalWinner = Data(repeating: 0x73, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding P. + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + // W's own row — this time claiming ONLY P, so the second sweep has + // no other input to reason about. + let w = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x06, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(w) + try context.save() + + // Second sweep: X beats W, still holding the same input. + sweep(handler, [Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400)]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let tombstoneDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + let tombstone = try XCTUnwrap(try context.fetch(tombstoneDescriptor).first) + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertEqual( + tombstone.spendingTxid, + finalWinner, + "the tombstone must be repointed at the FINAL winner, not the intermediate " + + "one the second sweep already removed" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + coin.isSpent, + "the final winner's claim must survive both sweeps and the funding UTXO's own arrival" + ) + XCTAssertEqual(coin.supersededByTxid, finalWinner) + } + + /// The multi-loser batch shape upstream's descendant closure always + /// produces — parent P and child C removed together — which no fixture + /// here ever exercised: C spends P:0, still unfunded, so the claim + /// lives as a pending row. Upstream never releases a loser-funded + /// outpoint, so without a co-swept check the sweep tombstones the + /// claim to the winner — and P's chainlocked reinstatement then + /// re-delivers P:0 straight into the tombstone-outranks drain: + /// `isSpent = true`, `supersededByTxid = winner`, recovery clear + /// refusing stamped holds. Permanently unspendable. A dead parent's + /// output is nobody's coin; the claim must be deleted with the batch. + func testABatchSweepingParentAndChildDeletesTheChildsClaimOnTheParentsOutput() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + // P is `fundingTxid` (so the redelivery helper reaches it) and its + // record was never persisted — the weaker-preconditions shape. C's + // claim on P:0 is parked as a pending row, exactly what + // `resolveInputOutpoint` writes. + let childTxid = Data(repeating: 0xB5, count: 32) // C + let winner = Data(repeating: 0xB6, count: 32) // W + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + + let c = PersistentTransaction( + txid: childTxid, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(c) + context.insert(PersistentPendingInput( + outpoint: pOutpoint, + inputIndex: 0, + spendingTxid: childTxid, + spendingTransaction: c, + walletId: walletId + )) + try context.save() + + // One batch removes both; upstream excludes P:0 from the released + // set because its funder is itself a loser. + sweep(handler, [Batch(losers: [fundingTxid, childTxid], winner: winner, winnerMinedHeight: 400)]) + + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + XCTAssertTrue( + try context.fetch(pendingDescriptor).isEmpty, + "a claim on a co-swept parent's output must be deleted, not tombstoned" + ) + + // The chainlocked return: P reinstated with its output re-delivered + // must land spendable — nothing the batch left behind may hold it. + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse( + coin.isSpent, + "the reinstated parent's output must not be wedged by its dead child's claim" + ) + XCTAssertNil(coin.supersededByTxid) + } + + /// The whole chain inside ONE round: a single sweeps callback can carry + /// two batches where the second sweeps the first's winner, so the + /// tombstone the first batch just wrote — staged, unsaved, retargeted by + /// nothing but in-memory mutation — must be visible to the second + /// batch's scalar reconciliation. Pins the per-batch tombstone scan + /// reading the mutable columns off live objects; a store-side predicate + /// would test the stale saved values and miss the row entirely. + func testChainedSweepAcrossTwoBatchesInOneRoundReleasesTheFreshTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0xA1, count: 32) // L + let secondLoser = Data(repeating: 0xA2, count: 32) // W — batch 1's winner + let finalWinner = Data(repeating: 0xA3, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // One callback, two batches: W beats L holding the unfunded coin, + // then X beats W and frees it. + sweep(handler, [ + Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400), + Batch( + losers: [secondLoser], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + ), + ]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + XCTAssertTrue( + try context.fetch(pendingDescriptor).isEmpty, + "the second batch must find and release the tombstone the first batch just wrote" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(coin.isSpent, "the released coin funds as spendable") + XCTAssertNil(coin.supersededByTxid) + } + + /// The funding-BEFORE-release ordering of the chained scenario above: + /// the funding TXO arrives between the sweep that held the coin and the + /// sweep that frees it, so the tombstone drains into + /// `PersistentTxo.supersededByTxid` and the pending row is gone by the + /// time the release runs. With the intermediate winner's own record on + /// hand the drain links `spendingTransaction` too, so the release DOES + /// reach the row through `row.inputs` — but nothing cleared the marker, + /// and a released coin keeping its dead winner's marker turns the next + /// hold on this outpoint permanent (`upsertUtxo`'s recovery clear reads + /// a present marker as a durable claim). + func testAReleasedCoinDropsItsDeadWinnersMarker() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x91, count: 32) // L + let secondLoser = Data(repeating: 0x92, count: 32) // W + let finalWinner = Data(repeating: 0x93, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding the still-unfunded coin. + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + // W's own record lands before the funding TXO does, so the drain + // below links `spendingTransaction` as well as stamping the marker. + let w = PersistentTransaction( + txid: secondLoser, + transactionData: Data(repeating: 0x06, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(w) + try context.save() + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let stamped = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(stamped.isSpent, "sanity: the drained claim holds the coin") + XCTAssertEqual(stamped.supersededByTxid, secondLoser) + + // Second sweep: X beats W, and this time upstream frees the coin. + sweep(handler, [ + Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ]) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(freed.isSpent, "the released coin is spendable again") + XCTAssertNil(freed.spendingTransaction) + XCTAssertNil( + freed.supersededByTxid, + "the dead winner's marker goes with the hold it carried" + ) + } + + /// The unreachable-claim variant of the same ordering: the claim + /// drained into `PersistentTxo.supersededByTxid`, its pending row is + /// gone, and the winner it names was NEVER recorded here — so when that + /// winner is swept in turn there is no `row` to fetch, no `row.inputs` + /// to walk, and no tombstone left for the scalar reconciliation to + /// find. Only an outpoint-keyed release — the form Kotlin's + /// `releaseByOutpoint` and SQLite's outpoint-matched UPDATE both + /// implement — can reach the coin; without it the release is silently + /// dropped and the coin stays spent forever. + func testAReleaseReachesAClaimDrainedToTheTxoWhenTheWinnerWasNeverRecorded() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x94, count: 32) // L + let unrecordedWinner = Data(repeating: 0x95, count: 32) // W — never a row here + let finalWinner = Data(repeating: 0x96, count: 32) // X + + let l = PersistentTransaction( + txid: firstLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -50_000 + ) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding the still-unfunded coin. + sweep(handler, [Batch(losers: [firstLoser], winner: unrecordedWinner, winnerMinedHeight: 400)]) + + // The funding TXO arrives with W still unrecorded: the drain stamps + // the marker but has no row to link. + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let stamped = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(stamped.isSpent, "sanity: the drained claim holds the coin") + XCTAssertEqual(stamped.supersededByTxid, unrecordedWinner) + XCTAssertNil(stamped.spendingTransaction, "sanity: no relationship to reach it by") + + // Second sweep: X beats the never-recorded W, freeing the coin. + sweep(handler, [ + Batch( + losers: [unrecordedWinner], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + ) + ]) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse( + freed.isSpent, + "the release must reach a drained claim even with no row and no tombstone left" + ) + XCTAssertNil(freed.supersededByTxid) + } + + /// The multi-wallet continuation of the chained scenarios above — the + /// review finding on the missing-row early return. A shared loser L + /// spends one still-unfunded coin of wallet A's and two of wallet B's, + /// so the first sweep leaves each wallet's claims as detached tombstones + /// pointing at winner W. When W's own record then arrives, + /// `resolveInputOutpoint`'s duplicate guard sees each `(outpoint, W)` + /// tombstone and attaches nothing to W's row — so when W is swept in + /// turn, wallet A's callback finds no other wallet's claim on the row + /// and deletes it. Wallet B's independently committed callback then runs + /// against a row that no longer exists, and before the fix returned + /// without ever applying B's release decision: B's released coin would + /// later come back spent by the obsolete W, and B's held coin stayed + /// attributed to W, unable to follow any further sweep. + func testSharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstones() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + let walletB = Data(repeating: 0x02, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + + let sharedLoser = Data(repeating: 0xC1, count: 32) // L + let sharedWinner = Data(repeating: 0xC2, count: 32) // W + let finalWinner = Data(repeating: 0xC3, count: 32) // X + + let l = PersistentTransaction( + txid: sharedLoser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -140_000 + ) + context.insert(l) + // None of the three coins L claims has been funded here yet: one of + // wallet A's (vout 0) and two of wallet B's (vouts 1 and 2), all + // parked as pending inputs the way `resolveInputOutpoint` does. + for (vout, owner) in [(UInt32(0), walletId), (1, walletB), (2, walletB)] { + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: vout), + inputIndex: vout, + spendingTxid: sharedLoser, + spendingTransaction: l, + walletId: owner + )) + } + try context.save() + + // First sweep, one independently committed callback per wallet: W + // beats L, holding everything (nothing funded, nothing released). + sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner, winnerMinedHeight: 400)], walletId: walletId) + sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner, winnerMinedHeight: 400)], walletId: walletB) + XCTAssertNil(transaction(container, txid: sharedLoser), "L is gone once both wallets ran") + + // W's own record arrives, claiming all three outpoints. The + // `(outpoint, W)` tombstones occupy the duplicate-guard key, so no + // new pending relationship attaches to W's row — the premise that + // lets wallet A's callback below delete it. + deliverReinstatingRecord( + handler, + walletId: walletId, + txid: sharedWinner, + context: 0, + blockHeight: 0, + inputOutpoints: [ + (txid: fundingTxid, vout: 0), + (txid: fundingTxid, vout: 1), + (txid: fundingTxid, vout: 2), + ], + outputVout: 0, + outputAmount: 120_000, + outputAddress: "yWinnerChange" + ) + + // Second sweep: X beats W. Wallet A's callback runs first, releases + // its own coin, and — finding no attached claim of any other + // wallet's — deletes the shared row. + sweep(handler, [ + Batch(losers: [sharedWinner], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + XCTAssertNil( + transaction(container, txid: sharedWinner), + "sanity: wallet A's callback deleted the shared winner row — the premise " + + "wallet B's callback below has to survive" + ) + + // Wallet B's callback arrives after the row is gone, releasing one + // of its two coins and holding the other. + sweep(handler, [ + Batch(losers: [sharedWinner], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 2)]) + ], walletId: walletB) + + let heldOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + let heldDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == heldOutpoint } + ) + let heldTombstone = try XCTUnwrap( + try context.fetch(heldDescriptor).first, + "wallet B's held tombstone must survive the row's absence" + ) + XCTAssertEqual( + heldTombstone.spendingTxid, + finalWinner, + "the held tombstone must follow the chain to X even though W's row was " + + "already deleted by wallet A's callback" + ) + let releasedOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 2) + let releasedDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == releasedOutpoint } + ) + XCTAssertTrue( + try context.fetch(releasedDescriptor).isEmpty, + "wallet B's release decision must reach its tombstone even though W's row " + + "was already deleted by wallet A's callback" + ) + + // The funding TXOs finally arrive, one per owning wallet. + deliverFundingUtxo(handler, walletId: walletId, vout: 0, amount: 100_000) + deliverFundingUtxo(handler, walletId: walletB, vout: 1, amount: 40_000) + deliverFundingUtxo(handler, walletId: walletB, vout: 2, amount: 20_000) + + let coinA = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(coinA.isSpent, "wallet A's released coin comes back spendable") + let heldB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(heldB.isSpent, "wallet B's held coin stays spent") + XCTAssertEqual( + heldB.supersededByTxid, + finalWinner, + "the held coin must be attributed to the final winner, not the deleted W" + ) + let releasedB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 2)) + XCTAssertFalse( + releasedB.isSpent, + "wallet B's released coin must not resurrect spent under the obsolete winner" + ) + XCTAssertNil(releasedB.supersededByTxid) + } + + /// Hand a UTXO for `(fundingTxid, vout)` back through the ordinary + /// account changeset — the same entry point `redeliverCoinB` drives, but + /// generalized so a fresh outpoint can be delivered rather than the one + /// baked into `seedSpend`. + private func deliverFundingUtxo( + _ handler: PlatformWalletPersistenceHandler, + vout: UInt32, + amount: UInt64 + ) { + deliverFundingUtxo(handler, walletId: walletId, vout: vout, amount: amount) + } + + /// `walletId`-parameterized form for the multi-wallet tests, where each + /// wallet's own funding UTXO has to arrive through that wallet's own + /// changeset — the drain in `upsertUtxo` resolves the tombstone by + /// outpoint, but the round itself is wallet-scoped like every real one. + private func deliverFundingUtxo( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data, + vout: UInt32, + amount: UInt64 + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup("yFundAddr") + defer { + free(name) + free(address) + } + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = vout + utxo.amount = amount + utxo.address = address + utxo.height = 100 + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Deliver a plain transaction record — with a fresh output of its own + /// riding along in the same round — through the ordinary account + /// changeset entry point. Models the reinstating event the BLOCKING + /// finding describes: upstream reports a previously-swept txid to + /// `records` exactly the way it reports any freshly-detected + /// transaction, with nothing on the wire flagging it as "the one that + /// used to be swept" — `upsertTransaction` has to infer that entirely + /// from the row it finds already sitting in the store. + private func deliverReinstatingRecord( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data, + txid: Data, + context: UInt32, + blockHeight: UInt32, + inputOutpoints: [(txid: Data, vout: UInt32)], + outputVout: UInt32, + outputAmount: UInt64, + outputAddress: String + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup(outputAddress) + defer { + free(name) + free(address) + } + + let inputs = UnsafeMutablePointer.allocate( + capacity: max(inputOutpoints.count, 1) + ) + for (i, input) in inputOutpoints.enumerated() { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + input.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = input.vout + inputs.advanced(by: i).initialize(to: entry) + } + defer { + inputs.deinitialize(count: inputOutpoints.count) + inputs.deallocate() + } + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = blockHeight + record.input_outpoints = inputs + record.input_outpoints_count = UInt(inputOutpoints.count) + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = outputVout + utxo.amount = outputAmount + utxo.address = address + utxo.height = blockHeight + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &record) { recordPtr in + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + // MARK: - Bounded tombstone lifetime + + /// The block-context winner's mined height used across the bounded- + /// lifetime tests — the stamp every tombstone carries, and the exact + /// boundary value at which it collects. + private static let winnerHeight: UInt32 = 400 + + /// One committed round carrying chain progress: the synced height, + /// (unless the caller opts out) opaque chainlock bytes, and — when + /// `chainLockHeight` is supplied — the NUMERIC chainlock height + /// through the extension's dedicated slot, fired inside the same + /// begin/end bracket after the changeset callback exactly the way the + /// Rust persister fires it. The bytes and the number are deliberately + /// independent knobs: the reviewer's point is precisely that bytes + /// alone must not enable collection. + private func heightsRound( + _ handler: PlatformWalletPersistenceHandler, + synced: UInt32, + chainLock: Bool = true, + chainLockHeight: UInt32? = nil + ) { + handler.beginChangeset(walletId: walletId) + var cs = WalletChangeSetFFI() + cs.has_chain = true + cs.chain.has_synced_height = true + cs.chain.synced_height = synced + var clBytes = [UInt8](repeating: 9, count: 84) + clBytes.withUnsafeMutableBufferPointer { buf in + if chainLock { + cs.last_applied_chain_lock_bytes = buf.baseAddress + cs.last_applied_chain_lock_bytes_len = UInt(buf.count) + } + withUnsafePointer(to: &cs) { csPtr in + _ = handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + if let chainLockHeight { + _ = handler.persistWalletChangesetChainLockHeight( + walletId: walletId, + height: chainLockHeight + ) + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Record a loser spending `(spentTxid, 0)` with the funding side + /// unobserved, then sweep it in the given winner context — + /// `winnerMinedHeight` non-nil leaves the stamped tombstone the + /// collection tests reason about; `nil` (an IS-locked, unmined winner) + /// must leave nothing. + private func seedSweptTombstone( + _ handler: PlatformWalletPersistenceHandler, + _ container: ModelContainer, + winnerMinedHeight: UInt32?, + spentTxid: Data? = nil, + loser: Data? = nil, + winner: Data? = nil + ) throws { + let loser = loser ?? sweptTxid + let context = ModelContext(container) + let swept = PersistentTransaction( + txid: loser, + transactionData: Data(repeating: 0x05, count: 10), + context: 0, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(swept) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: loser, + spendingTransaction: swept, + walletId: walletId + )) + try context.save() + sweep(handler, [Batch( + losers: [loser], + winner: winner ?? winnerTxid, + winnerMinedHeight: winnerMinedHeight + )]) + } + + private func pendingRows( + _ container: ModelContainer, + spentTxid: Data? = nil + ) throws -> [PersistentPendingInput] { + let outpoint = PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: 0) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try ModelContext(container).fetch(descriptor) + } + + /// Every pending-input row this wallet holds, regardless of outpoint — + /// the attacker-growth metric the mempool-context tests measure. + private func walletPendingRows( + _ container: ModelContainer + ) throws -> [PersistentPendingInput] { + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + return try ModelContext(container).fetch(descriptor) + } + + /// This wallet's persisted row, for asserting on the stored numeric + /// chainlock height. + private func walletRow(_ container: ModelContainer) throws -> PersistentWallet? { + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + return try ModelContext(container).fetch(descriptor).first + } + + /// The attacker-shaped row's lawful cousin: a block-context sweep's + /// tombstone stores the WINNER'S own mined height and is collected + /// exactly when the finality boundary `min(chainlockHeight, + /// syncedHeight)` reaches it — upstream key-wallet's + /// `prune_finalized_observed_spends` condition verbatim, no + /// observation-age margin. At that boundary the funding transaction of + /// the guarded outpoint (necessarily mined at or below the winner's + /// height) has been filter-scanned with no false negatives, so an + /// undrained tombstone is provably not guarding the wallet's coin. + func testASweptTombstoneIsCollectedAtFinalityAndNotBefore() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + let tombstone = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(tombstone.isSweptTombstone, "sanity: the sweep flagged the row") + XCTAssertEqual( + tombstone.winnerMinedHeight, Self.winnerHeight, + "the tombstone is stamped with the WINNER'S own mined height — " + + "not any observation watermark" + ) + + heightsRound( + handler, + synced: Self.winnerHeight - 1, + chainLockHeight: Self.winnerHeight - 1 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "boundary \(Self.winnerHeight - 1) has not reached the winner's " + + "height \(Self.winnerHeight) — the hold stays" + ) + + heightsRound(handler, synced: Self.winnerHeight, chainLockHeight: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the boundary reaching the winner's height collects the row — no margin" + ) + } + + /// The reviewer's "weaker still" point, named: synced-height progress + /// plus even PRESENT chainlock BYTES must not collect — the bincode + /// blob proves a chainlock was once applied, but says nothing about + /// how far finality reaches. Only the NUMERIC chainlock height + /// delivered through the extension slot supplies the boundary's + /// chainlock half, mirroring upstream's (and the SQLite store's) + /// "no-op until a chainlock height has been persisted". + func testASweptTombstoneOutlivesSyncProgressWithoutANumericChainLockHeight() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + heightsRound(handler, synced: 10_000, chainLock: true) + XCTAssertEqual( + try pendingRows(container).count, 1, + "chainlock BYTES exist and the synced height is far past the " + + "stamp — but no numeric chainlock height has ever been " + + "stored, so no finality boundary exists and the hold stays" + ) + + heightsRound(handler, synced: 10_000, chainLockHeight: 10_000) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the first NUMERIC chainlock height supplies the boundary and " + + "the long-aged stamp collects" + ) + } + + /// The genuine claim the tombstone exists for: its funding TXO arrives, + /// the drain moves the hold onto the TXO row (`supersededByTxid`) and + /// deletes the pending rows — so no amount of later boundary progress + /// may touch the materialised hold. + func testADrainedClaimIsImmuneToTheCollector() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + Self.winnerHeight, + "sanity: held, undrained, stamped with the winner's height" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "sanity: the drain consumed the pending rows" + ) + + heightsRound(handler, synced: 10_000, chainLockHeight: 10_000) + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the materialised claim's row survives collection" + ) + XCTAssertTrue(coin.isSpent, "still held spent by the winner's claim") + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// A held tombstone with a nil winner-height stamp is never collected. + /// The mempool-context sweep path writes exactly this shape — an + /// IS-locked, unmined winner has no finality horizon to stamp — and + /// legacy rows read identically. With no proof of finality the safe + /// reading is to hold it forever rather than guess. + /// Replaces the rejected back-fill design, which stamped such a row + /// with the current height and thereby fabricated a finality horizon. + func testATombstoneWithoutAWinnerHeightIsNeverCollected() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + // The real writer: an IS-context sweep of a loser whose funding + // TXO never arrived. + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + + // Two rounds, not one: a back-filling collector (the rejected + // design) would stamp the row on the first round and collect it on + // the second. + heightsRound(handler, synced: 1_000_000, chainLockHeight: 1_000_000) + heightsRound(handler, synced: 1_000_010, chainLockHeight: 1_000_010) + + let row = try XCTUnwrap( + try pendingRows(container).first, + "no winner height, no proof of finality — the hold outlasts any boundary" + ) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertNil( + row.winnerMinedHeight, + "and the stamp is never back-filled — that would fabricate the horizon" + ) + } + + /// A chained sweep that re-points a still-unfunded claim to a new + /// BLOCK-context winner also re-stamps it with THAT winner's mined + /// height: the claim now belongs to a spend anchored at a later block, + /// and its collection horizon moves with it. + func testARepointedTombstoneIsRestampedToTheLaterSweep() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + Self.winnerHeight, + "sanity: stamped with the first winner's mined height" + ) + + // The first winner is itself swept — by a winner mined 50 blocks + // later — the chained-sweep continuation that re-points the + // earlier tombstone (no row needed: the tombstone is found by the + // scalar `spendingTxid` it carries). + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: Self.winnerHeight + 50 + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight + 50, + "re-pointed to a later block-context winner ⇒ re-stamped to " + + "THAT winner's mined height" + ) + + // And the horizon moved with it: the old height no longer collects, + // the new one does. + heightsRound( + handler, + synced: Self.winnerHeight + 49, + chainLockHeight: Self.winnerHeight + 49 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "the boundary reaching only the FIRST winner's height must no " + + "longer collect the re-stamped claim" + ) + heightsRound( + handler, + synced: Self.winnerHeight + 50, + chainLockHeight: Self.winnerHeight + 50 + ) + XCTAssertTrue(try pendingRows(container).isEmpty) + } + + /// A mempool-context sweep — an InstantSend-locked winner that has not + /// mined — preserves an UNSTAMPED tombstone for every held-but-unfunded + /// input. Under DIP-10 the IS lock alone settles those inputs: upstream + /// deletes the loser and retains them in the account's + /// `spent_outpoints`, a hold with no height that no record survives to + /// rebuild (the winner need not be wallet-relevant). The tombstone is + /// that hold's only durable carrier — `CORE_SWEEP_REMOVAL` requires + /// every non-released input to keep a durable spend claim before its + /// funding TXO materializes — and it is unstamped because an IS-locked + /// winner has no mining deadline, so no boundary may ever collect it. + func testAMempoolContextSweepPreservesAnUnstampedTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + + for i in 0..<3 { + let spent = Data(repeating: UInt8(0x70 + i), count: 32) + try seedSweptTombstone( + handler, + container, + winnerMinedHeight: nil, + spentTxid: spent, + loser: Data(repeating: UInt8(0x80 + i), count: 32), + winner: Data(repeating: UInt8(0x90 + i), count: 32) + ) + let row = try XCTUnwrap( + try pendingRows(container, spentTxid: spent).first, + "an unmined IS-locked winner must leave a held tombstone for input #\(i)" + ) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertNil(row.winnerMinedHeight, "and it carries no finality stamp") + } + // Arbitrary chainlock/height advancement never collects an + // unstamped hold — two rounds, so a back-filling collector would + // be caught too. + heightsRound(handler, synced: 1_000_000, chainLockHeight: 1_000_000) + heightsRound(handler, synced: 1_000_010, chainLockHeight: 1_000_010) + XCTAssertEqual( + try walletPendingRows(container).count, 3, + "every unstamped hold outlasts any boundary — only funding " + + "materialization, a block-context re-stamp, or a release " + + "resolves one" + ) + } + + /// The mempool-context sweep still spend-marks a coin that HAS + /// materialised — that path is unchanged: the row carries real funding + /// data and `supersededByTxid` is its durable hold. The + /// never-materialised claim the same loser carries survives too, as an + /// unstamped tombstone — the pending row is the only durable carrier + /// of a hold upstream keeps in `spent_outpoints` and cannot rebuild + /// after the loser's record is gone. + func testAMempoolContextSweepStillSpendMarksAMaterialisedCoin() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + // The same loser also claims an input whose funding side was never + // observed — the shape that would have become a tombstone. + let unfundedTxid = Data(repeating: 0x77, count: 32) + let context = ModelContext(container) + let loserRow = try XCTUnwrap(transaction(container, txid: sweptTxid)) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: unfundedTxid, vout: 0), + inputIndex: 2, + spendingTxid: sweptTxid, + spendingTransaction: loserRow, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: nil + )]) + + let coinB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue( + coinB.isSpent, + "a materialised coin is spend-marked by the IS-locked winner exactly as before" + ) + XCTAssertEqual(coinB.supersededByTxid, winnerTxid) + let claim = try XCTUnwrap( + try pendingRows(container, spentTxid: unfundedTxid).first, + "while the never-materialised claim survives as a tombstone" + ) + XCTAssertTrue(claim.isSweptTombstone) + XCTAssertEqual(claim.spendingTxid, winnerTxid, "re-pointed at the winner") + XCTAssertNil(claim.winnerMinedHeight, "unstamped — the winner is unmined") + } + + /// The reviewer's named regression: an IS-locked winner sweeps on the + /// mempool path and never mines, the app restarts, chainlocks and + /// heights advance arbitrarily, and only then is the funding output + /// delivered. Under DIP-10 the IS lock already settled that input — + /// upstream deleted the loser and retained the hold in the account's + /// `spent_outpoints`, a set rebuilt from records on load that no + /// surviving record can reconstruct. The unstamped tombstone is the + /// claim's only durable carrier, so the funding delivery must drain + /// INTO it and land spent: crediting the coin would hand coin + /// selection an outpoint the network has provably consumed. + func testAFundingOutputArrivingAfterAMempoolSweepAndRestartLandsSpent() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("mempool-sweep-restart-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + do { + let (handler, container) = try makeHandler(url: storeURL) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + XCTAssertNil(transaction(container, txid: sweptTxid), "sanity: the loser is gone") + let tombstone = try XCTUnwrap( + try walletPendingRows(container).first, + "sanity: the mempool sweep left the hold behind" + ) + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertNil(tombstone.winnerMinedHeight, "unstamped — no finality horizon exists") + } + + // Restart: a fresh persister loading the same on-disk store, then + // arbitrary chainlock/height advancement while the winner stays + // unmined — none of it may collect the unstamped hold — and only + // then the funding delivery. + let (handler, container) = try makeHandler(url: storeURL) + heightsRound(handler, synced: 25_000, chainLockHeight: 25_000) + XCTAssertEqual( + try walletPendingRows(container).count, 1, + "the unstamped hold survives the restart and every boundary" + ) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertTrue( + coin.isSpent, + "an input the IS-locked winner consumed must never come back " + + "spendable — the sweep's claim outlives the restart" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid, "held by the winner the sweep named") + XCTAssertTrue( + try walletPendingRows(container).isEmpty, + "the claim drained into the TXO row" + ) + } + + /// The unrelated-advancement scenario, block-context half: the + /// chainlock can run arbitrarily far ahead, but while `syncedHeight` + /// sits below the winner's mined height the boundary has not reached + /// the spend and the hold must survive — the funding output could + /// still be delivered by the unscanned range. It collects the moment + /// the synced height catches up. + func testABlockContextTombstoneOutlivesUnrelatedAdvancementBelowItsWinnersHeight() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + // Chainlocks race ahead by thousands of blocks; the filter scan + // has only reached one block short of the winner. + heightsRound( + handler, + synced: Self.winnerHeight - 1, + chainLockHeight: Self.winnerHeight + 10_000 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "min(chainlock, synced) = \(Self.winnerHeight - 1) is below the " + + "winner's height — any amount of unrelated chainlock " + + "progress must not collect the hold" + ) + + // No fresh chainlock this round: the changeset-path collector runs + // off the STORED numeric height. + heightsRound(handler, synced: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the scan reaching the winner's height completes the boundary and collects" + ) + } + + /// The other direction of the chained case: an UNSTAMPED hold + /// (IS-context sweep) re-pointed by a later BLOCK-context sweep gains + /// that winner's stamp — the claim now belongs to a spend anchored in + /// a real block, so it enters the collectible set and the boundary + /// reaching the new winner's height collects it. One of the three + /// resolution channels that bound the unstamped population. + func testAnUnstampedTombstoneRestampedByABlockContextSweepBecomesCollectible() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + // IS-context sweep: the hold lands unstamped. + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + XCTAssertNil( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + "sanity: held and unstamped" + ) + + // The IS-locked first winner is itself beaten by a mined conflict + // still claiming the unfunded input — the chained-sweep + // continuation finds the tombstone by its scalar `spendingTxid`. + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: Self.winnerHeight + 50 + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight + 50, + "the block-context re-point stamps the previously unstamped hold" + ) + + heightsRound( + handler, + synced: Self.winnerHeight + 50, + chainLockHeight: Self.winnerHeight + 50 + ) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "once stamped, the ordinary finality boundary collects the row" + ) + } + + /// The IS-locked half of the chained case: an unmined winner re-points + /// the claim but must NOT disturb the earlier block-context stamp — + /// upstream's observed-spend entry is never retracted by an + /// unconfirmed conflict. Collection at the retained height stays sound + /// (the funding output is mined at or below the FIRST spender's height + /// regardless of who claims the coin now), so the row still collects + /// at that boundary. + func testAMempoolRepointedTombstoneKeepsItsBlockContextStamp() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + // The first winner is evicted by an IS-locked, unmined conflict. + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: nil + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight, + "an unmined winner re-points the claim without touching the " + + "earlier block-context stamp" + ) + + heightsRound(handler, synced: Self.winnerHeight, chainLockHeight: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the retained stamp still bounds the row: the funding output " + + "sits at or below the first spender's height, so the " + + "boundary reaching it proves delivery-or-never" + ) + } + + /// The chainlock-height extension callback stores monotonic-max on the + /// wallet row: chain locks only move forward, and a late or re-emitted + /// lower height must not walk the finality boundary backwards. + func testTheChainLockHeightCallbackStoresMonotonicMaxOnTheWalletRow() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + XCTAssertNil( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, + "sanity: fresh row, no numeric chainlock height yet" + ) + + heightsRound(handler, synced: 10, chainLockHeight: 500) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 500, + "the first height lands as stored" + ) + + heightsRound(handler, synced: 11, chainLockHeight: 300) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 500, + "a lower height must not walk the watermark backwards" + ) + + heightsRound(handler, synced: 12, chainLockHeight: 700) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 700, + "a higher height advances it" + ) + } +} diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py new file mode 100755 index 00000000000..179103e41fe --- /dev/null +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Freeze SwiftData model definitions for a released schema version. + +A `VersionedSchema` identifies a store by the checksum of the entities it +declares, so a released version may only reference model types whose shape +never changes again. This script copies each live `@Model` class as it +existed at a given commit into a nested type of the requested schema +enum (`extension DashSchemaV1 { final class PersistentX { ... } }`), one +file per model, under `Persistence/FrozenSchemas/`. + +The class body and the model's own extensions are copied; doc comments, +`public` modifiers and top-level enums are dropped. Extensions add no +stored properties (so they are not part of the entity) but the class body +may call into them, and the live definitions of the shared non-model types +(for example `MasternodeStatus`) stay the single source. The stored +properties, their optionality and defaults, `@Attribute`, `@Relationship`, +`#Index` and `#Unique` are what the checksum hashes, and they are copied +verbatim. + +Value types a model stores inline (Codable structs and raw enums SwiftData +expands into composite attributes, such as `ChangeControlRules` on +`PersistentToken`) are entity-hash inputs too, so they are frozen the same +way: `--value-types-file` names the live file they come from and +`--value-types` the types to copy into one nested file, and every frozen +model body then resolves those names to the nested copies. + +Usage: + freeze_schema_models.py --commit --schema DashSchemaV1 \ + [--value-types-file Persistence/Types/TokenTypes.swift \ + --value-types ChangeControlRules ...] \ + PersistentWallet PersistentAccount ... + +Run it from anywhere inside the repository. Regenerating a frozen file from +the same commit is a no-op, which is how a reviewer checks one. +""" + +import argparse +import os +import re +import subprocess +import sys + +MODELS_DIR = "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models" +OUT_DIR = "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas" + + +def repo_root(): + return subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], text=True + ).strip() + + +def source_at(commit, model): + path = f"{MODELS_DIR}/{model}.swift" + return subprocess.check_output(["git", "show", f"{commit}:{path}"], text=True) + + +def strip_comments(lines): + """Drop comment lines and `public`, collapsing the blank runs left behind.""" + out = [] + for line in lines: + stripped = line.strip() + if stripped.startswith("///") or stripped.startswith("//"): + continue + line = re.sub(r"\bpublic(\(set\))? ", "", line) + if line.strip() == "" and out and out[-1].strip() == "": + continue + out.append(line) + while out and out[-1].strip() == "": + out.pop() + return out + + +def block_end(lines, start): + """Index of the line closing the brace block that opens at `start`. + + Braces inside comment lines are ignored so a `// { ... }` note inside a + body cannot truncate the copy. + """ + depth = 0 + opened = False + for i in range(start, len(lines)): + stripped = lines[i].strip() + if stripped.startswith("//"): + continue + depth += lines[i].count("{") - lines[i].count("}") + opened = opened or "{" in lines[i] + if depth == 0 and opened: + return i + raise SystemExit("unbalanced braces") + + +def extract_value_type(source, name): + """A top-level `struct ` / `enum ` block, comments stripped.""" + lines = source.splitlines() + for i, line in enumerate(lines): + if re.match(rf"^(public )?(struct|enum) {name}\b", line): + return strip_comments(lines[i : block_end(lines, i) + 1]) + raise SystemExit(f"{name}: no top-level struct or enum found") + + +def extract_class(source, model): + """The `@Model ... final class { ... }` block, comments stripped.""" + lines = source.splitlines() + start = None + for i, line in enumerate(lines): + if re.match(rf"^(public )?final class {model}\b", line): + start = i + break + if start is None: + raise SystemExit(f"{model}: no top-level class found") + # Include the macro attributes directly above the class (`@Model`). + while start > 0 and lines[start - 1].startswith("@"): + start -= 1 + end = block_end(lines, start) + return strip_comments(lines[start : end + 1]) + + +def extract_extensions(source, model): + """Every top-level `extension { ... }` body, comments stripped.""" + lines = source.splitlines() + bodies = [] + for i, line in enumerate(lines): + if re.match(rf"^(public )?extension {model}\s*(:[^{{]*)?\{{", line): + end = block_end(lines, i) + inner = strip_comments(lines[i + 1 : end]) + conformance = re.search(r"extension \w+\s*(:[^{]*)\{", line) + bodies.append(((conformance.group(1).strip() if conformance else ""), inner)) + return bodies + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--commit", required=True, help="commit the live shapes are taken from") + parser.add_argument("--schema", required=True, help="e.g. DashSchemaV1") + parser.add_argument( + "--value-types-file", + help="repo-relative file the inline value types are copied from", + ) + parser.add_argument("--value-types", nargs="*", default=[]) + parser.add_argument("models", nargs="+") + args = parser.parse_args() + root = repo_root() + sha = subprocess.check_output( + ["git", "rev-parse", "--short=10", args.commit], text=True, cwd=root + ).strip() + header = ( + "import Foundation\n" + "import SwiftData\n" + "\n" + ) + if args.value_types: + source = subprocess.check_output( + ["git", "show", f"{args.commit}:{args.value_types_file}"], text=True + ) + bodies = [] + for name in args.value_types: + body = extract_value_type(source, name) + bodies.append("\n".join((" " + line) if line.strip() else "" for line in body)) + text = ( + header + + f"// Inline value types exactly as schema {args.schema} stored them, generated\n" + f"// by scripts/freeze_schema_models.py from {os.path.basename(args.value_types_file)}\n" + f"// at commit {sha}. SwiftData expands a stored Codable struct into composite\n" + "// attributes of the owning entity, so these shapes are inputs to that\n" + "// version's checksum just like the model's own properties. Do not edit.\n" + f"extension {args.schema} {{\n" + + "\n\n".join(bodies) + + "\n}\n" + ) + out = os.path.join( + root, OUT_DIR, f"{args.schema}+{os.path.basename(args.value_types_file)}" + ) + with open(out, "w") as f: + f.write(text) + print(out) + qualified = list(args.models) + list(args.value_types) + for model in args.models: + source = source_at(args.commit, model) + body = extract_class(source, model) + indented = "\n".join((" " + line) if line.strip() else "" for line in body) + extensions = "" + # An extension of a nested type does not see its sibling nested + # types by bare name (that lookup lands on the live top-level type), + # so model and frozen value-type names inside extension bodies are + # qualified. + sibling = re.compile( + r"(?