diff --git a/.github/actions/linux/action.yml b/.github/actions/linux/action.yml index 63319ac7e..76a4c9856 100644 --- a/.github/actions/linux/action.yml +++ b/.github/actions/linux/action.yml @@ -39,7 +39,7 @@ runs: - name: Build shell: bash run: | - ./misc/mkcargokit_options.sh ledger > rust/cargokit.yaml + ./misc/mkcargokit_options.sh "ledger,bundled-sapling-params" > rust/cargokit.yaml fastforge package --platform=linux --targets=deb,appimage - name: Build Flatpak if: ${{ inputs.arch == 'x86_64' && github.event_name != 'pull_request' }} diff --git a/.github/actions/mac/action.yml b/.github/actions/mac/action.yml index 88687b237..42be2d301 100644 --- a/.github/actions/mac/action.yml +++ b/.github/actions/mac/action.yml @@ -41,7 +41,7 @@ runs: - name: Build shell: bash run: | - ./misc/mkcargokit_options.sh ledger > rust/cargokit.yaml + ./misc/mkcargokit_options.sh "ledger,bundled-sapling-params" > rust/cargokit.yaml flutter build macos - name: Notarization shell: bash diff --git a/.github/actions/windows/action.yml b/.github/actions/windows/action.yml index 3c5906ff8..af1123c23 100644 --- a/.github/actions/windows/action.yml +++ b/.github/actions/windows/action.yml @@ -22,7 +22,7 @@ runs: export PUB_CACHE="$RUNNER_TEMP/.pub-cache" dart pub global activate fastforge echo "$RUNNER_TEMP/.pub-cache/bin" >> $GITHUB_PATH - ./misc/mkcargokit_options.sh ledger > rust/cargokit.yaml + ./misc/mkcargokit_options.sh "ledger,bundled-sapling-params" > rust/cargokit.yaml - name: Build shell: powershell run: | diff --git a/.github/workflows/build-graphql.yml b/.github/workflows/build-graphql.yml index a4e1f22bc..75918d997 100644 --- a/.github/workflows/build-graphql.yml +++ b/.github/workflows/build-graphql.yml @@ -25,7 +25,7 @@ jobs: sudo apt-get update sudo apt-get install -y pkg-config libudev-dev cd rust - cargo build --release --bin zkool_graphql --features=graphql + cargo build --release --bin zkool_graphql --features=graphql,bundled-sapling-params - name: Create Release if: startsWith(github.ref_name, 'zkool-v') uses: softprops/action-gh-release@v3 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index de626546f..50bf3555f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: - os: windows-latest target: windows env: - FLUTTER_VERSION: 3.44.2 + FLUTTER_VERSION: 3.47.1 permissions: id-token: write attestations: write diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml new file mode 100644 index 000000000..db8dd3018 --- /dev/null +++ b/.github/workflows/test-rust.yml @@ -0,0 +1,28 @@ +name: Test Rust + +on: + workflow_dispatch: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + test: + if: ${{ !startsWith(github.head_ref, 'release-please--branches--') }} + runs-on: ubuntu-latest + steps: + - name: Install RUST + uses: dtolnay/rust-toolchain@stable + - name: Checkout code + uses: actions/checkout@v6 + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + - name: Run tests + run: | + cd rust + cargo test --lib diff --git a/.github/workflows/wallet.yml b/.github/workflows/wallet.yml index abdf5fdb7..ef3d89d5f 100644 --- a/.github/workflows/wallet.yml +++ b/.github/workflows/wallet.yml @@ -29,7 +29,7 @@ jobs: sudo apt-get update sudo apt-get install -y pkg-config libudev-dev cd rust - cargo build --release --bin zkool_graphql --features=graphql + cargo build --release --bin zkool_graphql --features=graphql,bundled-sapling-params - name: Run pytest tests run: | cd tests diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 20a91f896..e55d3557e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "6.27.0" + ".": "6.28.0-rc.10" } diff --git a/CHANGELOG.md b/CHANGELOG.md index daf3240d9..506c9930a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,136 @@ # Changelog +## [6.28.0-rc.10](https://github.com/hhanh00/zkool2/compare/zkool-v6.28.0-rc.9...zkool-v6.28.0-rc.10) (2026-08-22) + + +### Features + +* **voting:** resumable recovery across network, server, and client failures ([a553da0](https://github.com/hhanh00/zkool2/commit/a553da0f7b54e04eea4d4a37827a1b8e4ca79ee9)) + + +### Bug Fixes + +* **account:** derive sapling address on file import and fail fast on corrupt stored addresses ([57db3c8](https://github.com/hhanh00/zkool2/commit/57db3c8848fe4c9824215a6180f8e5aa013d503d)) + +## [6.28.0-rc.9](https://github.com/hhanh00/zkool2/compare/zkool-v6.28.0-rc.8...zkool-v6.28.0-rc.9) (2026-08-20) + + +### Bug Fixes + +* **voting:** default vote node URL to the vote chain server when unset ([3186669](https://github.com/hhanh00/zkool2/commit/3186669e9eef9ea167f75f39ce360c415f444183)) + +## [6.28.0-rc.8](https://github.com/hhanh00/zkool2/compare/zkool-v6.28.0-rc.7...zkool-v6.28.0-rc.8) (2026-08-19) + + +### Bug Fixes + +* **settings:** keep transport selector within screen width (FittedBox + padding) ([0b3d72e](https://github.com/hhanh00/zkool2/commit/0b3d72ec73b97ff42156338436d406db24790b4f)) + +## [6.28.0-rc.7](https://github.com/hhanh00/zkool2/compare/zkool-v6.28.0-rc.6...zkool-v6.28.0-rc.7) (2026-08-18) + + +### Bug Fixes + +* **voting:** keep back navigation to account after vote submission ([9f7cf4f](https://github.com/hhanh00/zkool2/commit/9f7cf4fb98ef7b06ebe8a6fef20cab4faf859605)) + +## [6.28.0-rc.6](https://github.com/hhanh00/zkool2/compare/zkool-v6.28.0-rc.5...zkool-v6.28.0-rc.6) (2026-08-18) + + +### Bug Fixes + +* drop redundant pool predicate from diversifier index backfill ([370e06d](https://github.com/hhanh00/zkool2/commit/370e06d360b12220fb55dd5b075e12fbbd4941ca)) +* stop ref use after unmount when closing Folders page (issue 1203) ([d866c5e](https://github.com/hhanh00/zkool2/commit/d866c5e2c8c122f9c8842cbc0cf46fb936f3abfa)) + +## [6.28.0-rc.5](https://github.com/hhanh00/zkool2/compare/zkool-v6.28.0-rc.4...zkool-v6.28.0-rc.5) (2026-08-18) + + +### Bug Fixes + +* batch-load voting sessions on one pool connection; keep the pool free during delegation ([8375475](https://github.com/hhanh00/zkool2/commit/8375475c9e8201a9eae4cbd54c6286a56624b0bf)) +* build with Flutter 3.47.0 ([e6bd2fa](https://github.com/hhanh00/zkool2/commit/e6bd2fac8d9c4b8855c19f59bc15ca35e5366955)) +* bump Kotlin to 2.2.20 for Flutter 3.47.0 Android build ([581fbcf](https://github.com/hhanh00/zkool2/commit/581fbcfaec3ef40eeb16c69807d988b48e241281)) + +## [6.28.0-rc.4](https://github.com/hhanh00/zkool2/compare/zkool-v6.28.0-rc.3...zkool-v6.28.0-rc.4) (2026-08-18) + + +### Features + +* ballot page AppBar shows the round title ([5ec5ac8](https://github.com/hhanh00/zkool2/commit/5ec5ac87aac68c5f8a8f68d85389cf005189145d)) + + +### Bug Fixes + +* **ledger:** transparent-only signing + NU6.3 v5 workaround for hardware wallets ([#1208](https://github.com/hhanh00/zkool2/issues/1208)) ([6645459](https://github.com/hhanh00/zkool2/commit/6645459cc3fae8868d760fcb89e8112cf5fc964d)) +* pass one connection through voting DB reads; stop pool stalls on voting page ([ca3e4dd](https://github.com/hhanh00/zkool2/commit/ca3e4ddea58bf0e068c782a7c5f1bd6532a16aa0)) +* support flutter 3.47 (intl 0.20.3); fixed 6.2.0 rename scale→decimalDigits ([f399940](https://github.com/hhanh00/zkool2/commit/f399940a15689907d49eb8bba777b63290ea8185)) + +## [6.28.0-rc.3](https://github.com/hhanh00/zkool2/compare/zkool-v6.28.0-rc.2...zkool-v6.28.0-rc.3) (2026-08-17) + + +### Features + +* gate voting round actions on chain status; zero tally for voteless closed rounds ([1ca2716](https://github.com/hhanh00/zkool2/commit/1ca2716fd79b0db90d617bee6d2eed422a8238fb)) +* results page title shows the round title ([85b97d9](https://github.com/hhanh00/zkool2/commit/85b97d91c565a6aea8316b7a0aad757fa80c1d14)) +* show proposal title and selection in the ballot evidence ([890b398](https://github.com/hhanh00/zkool2/commit/890b3983bd755aade2499def6585525fc036888e)) +* show proposal titles and option labels in the tally results ([c99c0c4](https://github.com/hhanh00/zkool2/commit/c99c0c4b03578aec13dc9530323a8f61f9e1617b)) + + +### Bug Fixes + +* don't re-send recorded shares; refresh status after tracking ([1c3df78](https://github.com/hhanh00/zkool2/commit/1c3df78da4a66e4968dd0008e5fda1758971b370)) +* key ballot evidence proposals by id, not list position ([55f89e3](https://github.com/hhanh00/zkool2/commit/55f89e34d91cedcf0bb974225090371fda5e0b21)) +* parse vote-sdk tally entries per-entry decision and amount ([9f7f2ff](https://github.com/hhanh00/zkool2/commit/9f7f2ffdb8b595b407b5087631ae34d1578ee516)) +* render option labels with vote-sdk ids, not 1-based positions ([f6af47e](https://github.com/hhanh00/zkool2/commit/f6af47e3db3a5509e292869a9ef0271c64c65c29)) +* resolve share-window timing from the chain round status ([fc3ee70](https://github.com/hhanh00/zkool2/commit/fc3ee7027ee9e29751ee699a87177a5a7f26ab35)) +* submit helper shares from the confirmed votes' payloads ([c1b2226](https://github.com/hhanh00/zkool2/commit/c1b2226875846113b3485081d646141123cfe03e)) +* tally entries without a decision key are decision 0, not a total ([23f0097](https://github.com/hhanh00/zkool2/commit/23f009757e38829af8e013c0b145f0f83f705cd7)) + +## [6.28.0-rc.2](https://github.com/hhanh00/zkool2/compare/zkool-v6.28.0-rc.1...zkool-v6.28.0-rc.2) (2026-08-17) + + +### Features + +* show friendly round titles and option labels in the voting UI ([5f9e2c8](https://github.com/hhanh00/zkool2/commit/5f9e2c825e4b0a2b379d077808cfa7c5e5588381)) +* show per-vote on-chain evidence on the done and confirmation screens ([f7f6093](https://github.com/hhanh00/zkool2/commit/f7f6093af04760544ef100796938291fd12d74df)) + + +### Bug Fixes + +* auto-create the voting hotkey at delegation time ([8e2fcc5](https://github.com/hhanh00/zkool2/commit/8e2fcc509dc4e5239c482c5a1881bb00a8902574)) +* bundle sapling params in desktop and graphql builds ([83565d8](https://github.com/hhanh00/zkool2/commit/83565d8d0d0024bccc80bffcc77a06722b4b09c8)) +* cast one proposal at a time so the vote VAN chains ([bbf85e6](https://github.com/hhanh00/zkool2/commit/bbf85e6e85a39900c3db46ddc066befc19bafb1f)) +* exclude post-snapshot notes from delegation prepare ([a81a4ff](https://github.com/hhanh00/zkool2/commit/a81a4ffe0be1064ae94ff86a7d4f6175101ff983)) +* fall back to the configured vote servers for helper shares ([7417291](https://github.com/hhanh00/zkool2/commit/74172914302ba70ab95c0169e388b150f8a3dbec)) +* honest done label when only time-scheduled steps remain ([0b1b316](https://github.com/hhanh00/zkool2/commit/0b1b316db9f86aecd84a13d3287c87d690ccede8)) +* make delegation confirmation real and recoverable ([eac4655](https://github.com/hhanh00/zkool2/commit/eac46550883426bd1fd6c485c01af4da8352ebdc)) +* parse the chain's string block heights; prove with voting-circuits 0.10.0 ([b22d304](https://github.com/hhanh00/zkool2/commit/b22d3046eb593b284a971b55d5c3b34acff36446)) +* run fresh voting submissions end-to-end (delegate + vote) ([13f2f31](https://github.com/hhanh00/zkool2/commit/13f2f31b0d242c71bb4af6bb8f6bd50a441bf211)) +* run the PIR connect inside the prove thread ([cb51c89](https://github.com/hhanh00/zkool2/commit/cb51c893221aef78b6a80e434bba4ccc9727d559)) +* use app lightwalletd URL for fresh delegation prepare ([76d231e](https://github.com/hhanh00/zkool2/commit/76d231e98c9143d15d4e0fda5937e2ba7e62b5bd)) +* use the async PIR client for delegation proving ([726f296](https://github.com/hhanh00/zkool2/commit/726f296dd3d5567644e4eae89e47f4cbdd06934c)) + +## [6.28.0-rc.1](https://github.com/hhanh00/zkool2/compare/zkool-v6.28.0-rc...zkool-v6.28.0-rc.1) (2026-08-16) + + +### Features + +* show voting power on voting confirmation and status pages ([73db966](https://github.com/hhanh00/zkool2/commit/73db966629505b865dee82c6b1c3767a03b7a57a)) + + +### Bug Fixes + +* decode trailing escaped spaces in OA1 records ([1bd7374](https://github.com/hhanh00/zkool2/commit/1bd7374585290a93bab5287e1e0b8fcf612e76ed)) +* persist ballot intents at cast time; parse option index ([04e5358](https://github.com/hhanh00/zkool2/commit/04e53587f17f97547b51055ab09898114786fd6e)) +* save settings on nav away; resolve voting config from form value ([252ab2e](https://github.com/hhanh00/zkool2/commit/252ab2e3379cff5ec72b315a9a5844ea8dd603ed)) +* surface voting config resolve errors instead of swallowing them ([c9516db](https://github.com/hhanh00/zkool2/commit/c9516db23ebe960cc05393eaba7fea54baa14d0d)) + +## [6.28.0-rc](https://github.com/hhanh00/zkool2/compare/zkool-v6.27.0...zkool-v6.28.0-rc) (2026-08-15) + + +### Features + +* shielded voting UI (ZIP 262) ([1e5564c](https://github.com/hhanh00/zkool2/commit/1e5564ca8808768b2b2959484e7372f76a2083d2)) + ## [6.27.0](https://github.com/hhanh00/zkool2/compare/zkool-v6.26.1...zkool-v6.27.0) (2026-08-15) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08b18b5e8..a59db3321 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,2 +1,4 @@ Because this is a solo project, I prefer that contributors reach out and ask first before submitting any PR. + +When opening a PR from a fork, please enable “Allow edits from maintainers” so maintainers can make fixes directly to your PR. diff --git a/Cargo.lock b/Cargo.lock index b7b724f96..d0f06cbea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8207,8 +8207,7 @@ dependencies = [ [[package]] name = "pir-client" version = "0.4.0-rc.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf6547ec35c8d4af154bc73d60fe2773be50f7c089e79414983d721d0dbc13db" +source = "git+https://github.com/hhanh00/vote-nullifier-pir.git?rev=b704640df339a98330a5b8e4582144fde67d0b0c#b704640df339a98330a5b8e4582144fde67d0b0c" dependencies = [ "anyhow", "ff", @@ -9377,6 +9376,7 @@ dependencies = [ "dataloader", "ed25519-dalek", "env_logger", + "ff", "figment", "flutter_rust_bridge", "fpdec", @@ -9408,6 +9408,7 @@ dependencies = [ "nym-sdk", "nym-smolmix", "orchard", + "pasta_curves", "pczt", "prost 0.14.4", "qrcode", @@ -9442,6 +9443,7 @@ dependencies = [ "tracing-subscriber", "uuid", "vcard4", + "vote-commitment-tree", "warp", "webpki-roots 1.0.9", "x25519-dalek", @@ -13211,7 +13213,7 @@ checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" [[package]] name = "vote-commitment-tree" version = "0.4.0-rc.2" -source = "git+https://github.com/hhanh00/zcash_voting.git?rev=fd4362b097579782565de553b7b8c5a612cda1e2#fd4362b097579782565de553b7b8c5a612cda1e2" +source = "git+https://github.com/hhanh00/zcash_voting.git?rev=fccb06451cbc5adde7c274abac08d9e91281e2a4#fccb06451cbc5adde7c274abac08d9e91281e2a4" dependencies = [ "anyhow", "ff", @@ -13227,7 +13229,7 @@ dependencies = [ [[package]] name = "vote-commitment-tree-client" version = "0.6.0-rc.2" -source = "git+https://github.com/hhanh00/zcash_voting.git?rev=fd4362b097579782565de553b7b8c5a612cda1e2#fd4362b097579782565de553b7b8c5a612cda1e2" +source = "git+https://github.com/hhanh00/zcash_voting.git?rev=fccb06451cbc5adde7c274abac08d9e91281e2a4#fccb06451cbc5adde7c274abac08d9e91281e2a4" dependencies = [ "base64 0.22.1", "ff", @@ -13241,8 +13243,8 @@ dependencies = [ [[package]] name = "voting-circuits" -version = "0.9.0-rc.3" -source = "git+https://github.com/hhanh00/voting-circuits.git?rev=9b408e712f8a2db8ca0b006846b310c6fd994941#9b408e712f8a2db8ca0b006846b310c6fd994941" +version = "0.10.0" +source = "git+https://github.com/hhanh00/voting-circuits.git?rev=44033690874bc8b92cf015426d785b0b2e107035#44033690874bc8b92cf015426d785b0b2e107035" dependencies = [ "blake2b_simd", "ff", @@ -13259,6 +13261,56 @@ dependencies = [ "sinsemilla", ] +[[package]] +name = "wagyu-zcash-parameters" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c904628658374e651288f000934c33ef738b2d8b3e65d4100b70b395dbe2bb" +dependencies = [ + "wagyu-zcash-parameters-1", + "wagyu-zcash-parameters-2", + "wagyu-zcash-parameters-3", + "wagyu-zcash-parameters-4", + "wagyu-zcash-parameters-5", + "wagyu-zcash-parameters-6", +] + +[[package]] +name = "wagyu-zcash-parameters-1" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bf2e21bb027d3f8428c60d6a720b54a08bf6ce4e6f834ef8e0d38bb5695da8" + +[[package]] +name = "wagyu-zcash-parameters-2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616ab2e51e74cc48995d476e94de810fb16fc73815f390bf2941b046cc9ba2c" + +[[package]] +name = "wagyu-zcash-parameters-3" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14da1e2e958ff93c0830ee68e91884069253bf3462a67831b02b367be75d6147" + +[[package]] +name = "wagyu-zcash-parameters-4" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f058aeef03a2070e8666ffb5d1057d8bb10313b204a254a6e6103eb958e9a6d6" + +[[package]] +name = "wagyu-zcash-parameters-5" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffe916b30e608c032ae1b734f02574a3e12ec19ab5cc5562208d679efe4969d" + +[[package]] +name = "wagyu-zcash-parameters-6" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b6d5a78adc3e8f198e9cd730f219a695431467f7ec29dcfc63ade885feebe1" + [[package]] name = "walkdir" version = "2.5.0" @@ -14297,6 +14349,7 @@ dependencies = [ "redjubjub", "sapling-crypto", "tracing", + "wagyu-zcash-parameters", "xdg", "zcash_primitives", ] @@ -14365,7 +14418,7 @@ dependencies = [ [[package]] name = "zcash_voting" version = "2.0.0-rc.5" -source = "git+https://github.com/hhanh00/zcash_voting.git?rev=fd4362b097579782565de553b7b8c5a612cda1e2#fd4362b097579782565de553b7b8c5a612cda1e2" +source = "git+https://github.com/hhanh00/zcash_voting.git?rev=fccb06451cbc5adde7c274abac08d9e91281e2a4#fccb06451cbc5adde7c274abac08d9e91281e2a4" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index cf6b0bc46..b92c19a89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,5 +48,13 @@ zcash_spec = { git = "https://github.com/zcash-shielded-assets/zcash_spec", rev halo2_gadgets = { git = "https://github.com/zcash-shielded-assets/halo2", rev = "dcdbd1df2b30fd8d709c1a7c4ad9c4e091789d00" } halo2_proofs = { git = "https://github.com/zcash-shielded-assets/halo2", rev = "dcdbd1df2b30fd8d709c1a7c4ad9c4e091789d00" } halo2_poseidon = { git = "https://github.com/zcash-shielded-assets/halo2", rev = "dcdbd1df2b30fd8d709c1a7c4ad9c4e091789d00" } -voting-circuits = { git = "https://github.com/hhanh00/voting-circuits.git", rev = "9b408e712f8a2db8ca0b006846b310c6fd994941" } +# 0.10.0 release source + the ZSA-orchard note API shim (from_parts asset +# arg), matching the chain's verifier. Branch feat/zsa-orchard-0.10. +voting-circuits = { git = "https://github.com/hhanh00/voting-circuits.git", rev = "44033690874bc8b92cf015426d785b0b2e107035" } +# Patched pir-client: the async PirClient lacks a public circuit_root +# accessor (private field), which the fork's PirProofSource trait needs. +# Branch feat/async-circuit-root-getter. NOTE: patches only apply from the +# workspace root manifest. +pir-client = { git = "https://github.com/hhanh00/vote-nullifier-pir.git", rev = "b704640df339a98330a5b8e4582144fde67d0b0c" } + diff --git a/analysis_options.yaml b/analysis_options.yaml index 3f066968d..8900b6e5c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -30,3 +30,12 @@ linter: formatter: page_width: 160 +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** diff --git a/android/settings.gradle b/android/settings.gradle index ea127485a..7cffcd0ee 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "8.13.0" apply false - id "org.jetbrains.kotlin.android" version "2.2.10" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false } include ":app" diff --git a/build_number.txt b/build_number.txt index 947e93bc2..ec9163d78 100644 --- a/build_number.txt +++ b/build_number.txt @@ -1 +1 @@ -341 +352 diff --git a/lib/pages/folder.dart b/lib/pages/folder.dart index 22dcf7e1e..55573c3db 100644 --- a/lib/pages/folder.dart +++ b/lib/pages/folder.dart @@ -13,29 +13,53 @@ class FolderPage extends ConsumerStatefulWidget { class FolderPageState extends ConsumerState { late final c = coinContext.coin; + late final ProviderContainer container; + late final SelectedFolder selectedFolderNotifier; List<(Folder, bool)> folders = []; int? selectedIndex; @override void initState() { super.initState(); - Future(refresh); + // Capture these now: `ref` is unsafe to use once the widget is unmounted, + // so the disposal path must not touch it. `containerOf(listen: false)` + // avoids the inherited-widget dependency that `ref.container` creates + // (not allowed in initState). + container = ProviderScope.containerOf(context, listen: false); + selectedFolderNotifier = ref.read(selectedFolderProvider.notifier); + _load(); } @override void dispose() { - Future(refresh); + // Unselect the folder if it was deleted while the page was open. Runs on + // a later tick and never uses `ref`. + Future(() async { + final selected = container.exists(selectedFolderProvider) + ? container.read(selectedFolderProvider) + : null; + if (selected == null) return; + final foldrs = await listFolders(c: c); + refresh(foldrs, selected); + }); super.dispose(); } - Future refresh() async { + // Fetches the latest folders and applies them to the page. `ref` is only + // used before the await, so this stays safe if the widget unmounts mid-load. + Future _load() async { final foldrs = await ref.read(getFoldersProvider.future); - final selectedFolder = ref.read(selectedFolderProvider); + refresh(foldrs, container.read(selectedFolderProvider)); + } + + // Applies a folder list to the page. Takes everything it needs as + // parameters and never touches `ref`, so it is safe to call after unmount. + void refresh(List foldrs, Folder? selectedFolder) { if (selectedFolder != null) { selectedIndex = foldrs.indexWhere((f) => f.id == selectedFolder.id); if (selectedIndex == -1) { selectedIndex = null; - (ref.read(selectedFolderProvider.notifier)).unselect(); + if (container.exists(selectedFolderProvider)) selectedFolderNotifier.unselect(); } } folders = foldrs.map((f) => (f, false)).toList(); @@ -86,7 +110,7 @@ class FolderPageState extends ConsumerState { final folderName = await inputText(context, title: "New Folder"); if (folderName != null) { await createNewFolder(name: folderName, c: c); - await refresh(); + await _load(); } } @@ -94,7 +118,7 @@ class FolderPageState extends ConsumerState { final folderName = await inputText(context, title: "Rename Folder"); if (folderName != null) { await renameFolder(id: selection.first.id, name: folderName, c: c); - await refresh(); + await _load(); } } @@ -102,7 +126,7 @@ class FolderPageState extends ConsumerState { final confirmed = await confirmDialog(context, title: "Do you want to delete these folders?", message: "Accounts assigned to these folders will be kept."); if (confirmed) { await deleteFolders(ids: selection.map((f) => f.id).toList(), c: c); - await refresh(); + await _load(); ref.invalidate(getAccountsProvider); } } diff --git a/lib/pages/splash.dart b/lib/pages/splash.dart index 19f729676..342460bd4 100644 --- a/lib/pages/splash.dart +++ b/lib/pages/splash.dart @@ -106,6 +106,11 @@ class SplashPageState extends ConsumerState { c = c.setTransport(transport: settings.transport); c = c.setProxy(proxy: settings.proxy); coinContext.set(coin: c); + // Re-arm helper-share tracking for rounds with pending share work, so a + // client restart resumes share delivery without visiting the voting page. + if (settings.votingConfigUrl.isNotEmpty) { + unawaited(Future(() => armShareTrackingForPendingRounds(ref))); + } final synchronizer = ref.read(synchronizerProvider.notifier); synchronizer.autoSync(); final mempool = ref.read(mempoolProvider.notifier); diff --git a/lib/pages/voting_confirmation.dart b/lib/pages/voting_confirmation.dart index 588d2af87..e8f8a7929 100644 --- a/lib/pages/voting_confirmation.dart +++ b/lib/pages/voting_confirmation.dart @@ -2,13 +2,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:zkool/main.dart'; +import 'package:zkool/src/rust/api/voting.dart'; import 'package:zkool/store.dart'; +import 'package:zkool/utils.dart'; /// Receipt screen shown after the submission job completes. class VotingConfirmationPage extends ConsumerStatefulWidget { final String roundId; + final String? roundName; + final String chainUrl; - const VotingConfirmationPage({super.key, required this.roundId}); + const VotingConfirmationPage({ + super.key, + required this.roundId, + this.roundName, + this.chainUrl = "", + }); @override ConsumerState createState() => @@ -21,6 +30,40 @@ class VotingConfirmationPageState extends ConsumerState final pinlock = ref.watch(lifecycleProvider); if (pinlock.value ?? false) return PinLock(); + final job = ref.watch(votingSubmissionJobProvider(widget.roundId)); + // Confirmed vote txs (per proposal), shown as on-chain evidence. A vote + // confirmed via commitment-tree recovery has no tx hash — it still + // counts as confirmed evidence. + final confirmedVotes = (ref + .watch(votingSessionProvider(widget.roundId)) + .value + ?.recovery + ?.votes ?? + const []) + .where((v) => v.phase == "confirmed") + .toList(); + // Human-readable ballot evidence: proposal titles and option labels, + // keyed by proposal id (not list position). + final proposals = { + for (final p in (ref + .watch(votingRoundProposalsProvider( + widget.roundId, + widget.chainUrl, + )) + .value ?? + const [])) + p.id: p, + }; + + String voteLabel(VotingVoteRecovery v) { + final proposal = proposals[v.proposalId]; + final title = proposal?.title ?? "Proposal ${v.proposalId}"; + // Option ids are the vote-sdk `index` values (omitted = 0 for the + // first); never render 1-based list positions. + final choice = proposal?.optionLabels[v.choice] ?? "Option ${v.choice}"; + return "$title — $choice"; + } + return Scaffold( appBar: AppBar(title: const Text("Vote submitted")), body: Center( @@ -37,13 +80,41 @@ class VotingConfirmationPageState extends ConsumerState ), const SizedBox(height: 16), Text( - "Your vote for ${widget.roundId} has been submitted.", + widget.roundName != null && + widget.roundName!.isNotEmpty && + widget.roundName != widget.roundId + ? "Your vote for ${widget.roundName} has been submitted." + : "Your vote for ${widget.roundId} has been submitted.", style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center, ), + if (job.eligibleWeightZatoshi != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + "Voting power: ${formatVotingPower(job.eligibleWeightZatoshi!)}", + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ), + for (final v in confirmedVotes) + Padding( + padding: const EdgeInsets.only(top: 8), + child: SelectableText( + v.txHash != null && v.txHash!.isNotEmpty + ? "${voteLabel(v)}\n${v.txHash} · tree ${v.vcTreePosition}" + : "${voteLabel(v)}\ntree ${v.vcTreePosition} · " + "verified in the commitment tree", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), const SizedBox(height: 24), FilledButton( - onPressed: () => GoRouter.of(context).go("/voting"), + // The submission flow uses pushReplacement, so the stack + // below is [account, /voting] — pop back to the polls page, + // whose back arrow still returns to the account page. + onPressed: () => GoRouter.of(context).pop(), child: const Text("Done"), ), ], diff --git a/lib/pages/voting_polls.dart b/lib/pages/voting_polls.dart index 0ad5e6e03..03d2707e6 100644 --- a/lib/pages/voting_polls.dart +++ b/lib/pages/voting_polls.dart @@ -11,9 +11,17 @@ import 'package:zkool/utils.dart'; import 'package:zkool/widgets/error_display.dart'; /// Round list for shielded voting (ZIP 262). Each row derives its action -/// label from the fork's resume plan (`primary_action`), so a restart shows -/// the correct "Resume"/"Start"/"View results" affordance without any Dart -/// session state. +/// label from the fork's resume plan (`primary_action`) plus the chain round +/// status, so a restart shows the correct affordance without any Dart session +/// state: +/// +/// - chain tallying/closed → "View results" (a tally exists). +/// - chain active, plan has recovery work (incl. shares sent but unconfirmed) +/// → "Resume" → status page, which re-arms share tracking. +/// - chain active, wallet done → "Review" → vote receipt (no tally yet). +/// - otherwise → "Start voting". +/// +/// An unresolved chain status falls back to the plan-only rule. class VotingPollsPage extends ConsumerStatefulWidget { const VotingPollsPage({super.key}); @@ -29,11 +37,19 @@ class VotingPollsPageState extends ConsumerState { super.initState(); Future(_guardHardwareAccount); Future(() async { - final settings = await ref.read(appSettingsProvider.future); - if (settings.votingConfigUrl.isNotEmpty) { - await ref.read(votingConfigProvider.notifier).resolve(); + try { + final settings = await ref.read(appSettingsProvider.future); + if (settings.votingConfigUrl.isNotEmpty) { + await ref.read(votingConfigProvider.notifier).resolve(); + } + } on AnyhowException catch (e) { + if (mounted) await showException(context, e.message); } }); + // Opening the voting page re-arms helper-share tracking for rounds with + // pending share work, so a restart resumes delivery without a manual + // status-page visit. + Future(() => armShareTrackingForPendingRounds(ref)); } /// Voting v1 supports software accounts only; the fork signs with the @@ -64,15 +80,20 @@ class VotingPollsPageState extends ConsumerState { final config = ref.watch(votingConfigProvider); final rounds = ref.watch(votingRoundListProvider); + final shareAttention = ref.watch(votingShareTrackerProvider); return Scaffold( appBar: AppBar( title: Text("Voting"), actions: [ IconButton( icon: Icon(Icons.refresh), - onPressed: () { + onPressed: () async { ref.invalidate(votingRoundListProvider); - ref.read(votingConfigProvider.notifier).resolve(); + try { + await ref.read(votingConfigProvider.notifier).resolve(); + } on AnyhowException catch (e) { + if (context.mounted) await showException(context, e.message); + } }, ), ], @@ -83,21 +104,44 @@ class VotingPollsPageState extends ConsumerState { data: (list) { final configRounds = config.value?.rounds ?? const []; final localIds = list.map((r) => r.roundId).toSet(); + final chainUrl = (config.value != null && + config.value!.voteServers.isNotEmpty) + ? config.value!.voteServers.first.url + : ""; + // Only rounds the chain reports as active are joinable: a closed + // or tallying round has no open voting window, so it is not + // offered. An unresolved status (fetch in flight or failed) keeps + // the round visible rather than hiding it. final joinable = configRounds .where((r) => !localIds.contains(r.roundId)) + .where((r) { + final status = ref + .watch(votingRoundStatusProvider(r.roundId, chainUrl)) + .value; + return status == null || status == "active"; + }) .toList(); if (list.isEmpty && joinable.isEmpty) { return const Center(child: Text("No voting rounds")); } - final chainUrl = (config.value != null && - config.value!.voteServers.isNotEmpty) - ? config.value!.voteServers.first.url - : ""; return RefreshIndicator( onRefresh: () async => ref.invalidate(votingRoundListProvider), child: ListView( children: [ + if (shareAttention) + const Padding( + padding: EdgeInsets.fromLTRB(12, 8, 12, 0), + child: Card( + child: Padding( + padding: EdgeInsets.all(12), + child: Text( + "Helper-share delivery is pending — it will retry " + "in the background while the round is open.", + ), + ), + ), + ), if (joinable.isNotEmpty) ...[ const Padding( padding: EdgeInsets.all(12), @@ -105,23 +149,28 @@ class VotingPollsPageState extends ConsumerState { style: TextStyle(fontWeight: FontWeight.bold)), ), ...joinable.map( - (r) => ListTile( - title: Text(r.roundId), - trailing: FilledButton.tonal( - onPressed: chainUrl.isEmpty - ? null - : () => GoRouter.of(context) - .push("/voting/proposal", extra: { - "roundId": r.roundId, - "chainUrl": chainUrl, - }), - child: const Text("Join"), - ), - ), + (r) { + final title = ref.watch( + votingRoundTitleProvider(r.roundId, chainUrl), + ); + return ListTile( + title: Text(title.value ?? r.roundId), + trailing: FilledButton.tonal( + onPressed: chainUrl.isEmpty + ? null + : () => GoRouter.of(context) + .push("/voting/proposal", extra: { + "roundId": r.roundId, + "chainUrl": chainUrl, + }), + child: const Text("Join"), + ), + ); + }, ), const Divider(), ], - ...list.map((r) => _RoundTile(round: r)), + ...list.map((r) => _RoundTile(round: r, chainUrl: chainUrl)), ], ), ); @@ -133,15 +182,19 @@ class VotingPollsPageState extends ConsumerState { class _RoundTile extends ConsumerWidget { final VotingRoundInfo round; + final String chainUrl; - const _RoundTile({required this.round}); + const _RoundTile({required this.round, required this.chainUrl}); - String _actionLabel(String primaryAction) { + String _actionLabel(String primaryAction, {bool pendingRecovery = false}) { switch (primaryAction) { case "delegate" || "vote" || "submit_shares": return "Resume"; case "done": - return "View results"; + // "done" with recovery steps still pending (e.g. helper shares sent + // but not confirmed) is not finished — keep "Resume" so the status + // page, which re-arms share tracking, stays reachable. + return pendingRecovery ? "Resume" : "View results"; default: return "Start voting"; } @@ -150,10 +203,14 @@ class _RoundTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final cs = Theme.of(context).colorScheme; - final session = ref.watch(votingSessionProvider(round.roundId)); - return session.when( + // All tiles share one batch load (a single pool connection); the tile + // reads its round's session from the shared map. + final sessions = ref.watch(votingSessionsAllProvider); + final title = ref.watch(votingRoundTitleProvider(round.roundId, chainUrl)); + final roundTitle = title.value ?? round.roundId; + return sessions.when( loading: () => ListTile( - title: Text(round.roundId), + title: Text(roundTitle), subtitle: Text("Snapshot height ${round.snapshotHeight}"), trailing: SizedBox( width: 24, @@ -162,28 +219,65 @@ class _RoundTile extends ConsumerWidget { ), ), error: (e, _) => ListTile( - title: Text(round.roundId), + title: Text(roundTitle), subtitle: Text("Snapshot height ${round.snapshotHeight}"), trailing: IconButton( icon: Icon(Icons.refresh), - onPressed: () => - ref.invalidate(votingSessionProvider(round.roundId)), + onPressed: () => ref.invalidate(votingSessionsAllProvider), ), ), - data: (state) { - final action = state.plan?.primaryAction ?? "idle"; - final label = _actionLabel(action); + data: (map) { + final state = map[round.roundId]; + if (state == null) { + return ListTile( + title: Text(roundTitle), + subtitle: Text("Snapshot height ${round.snapshotHeight}"), + trailing: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + final plan = state.plan; + final action = plan?.primaryAction ?? "idle"; + final pending = plan?.pendingRecovery ?? false; + // The chain round status decides whether results exist: an active + // round has no published tally, so the tile must not offer + // "View results" even when the wallet's part is done — the plan only + // picks between "Resume" (work pending) and "Review" (done). An + // unresolved chain status (fetch failed / no chain URL) falls back + // to the plan-only rule. + final chainStatus = ref + .watch(votingRoundStatusProvider(round.roundId, chainUrl)) + .value; + final chainDone = chainStatus == "tallying" || chainStatus == "closed"; + final planDone = action == "done" && !pending; + final done = chainDone || (chainStatus == null && planDone); + final review = action == "done" && !done && chainStatus != null; + final label = done + ? "View results" + : review + ? "Review" + : _actionLabel(action, pendingRecovery: pending); return ListTile( - title: Text(round.roundId), + title: Text(roundTitle), subtitle: Text( "Snapshot height ${round.snapshotHeight} • " "${round.bundleCount} bundle${round.bundleCount == 1 ? "" : "s"}", ), trailing: FilledButton.tonal( - onPressed: () => _openStatus(context, ref, action), + onPressed: () => _openStatus( + context, + ref, + action, + done: done, + review: review, + roundName: roundTitle, + ), child: Text(label), ), - selected: action != "idle" && action != "done", + selected: pending && !done, selectedTileColor: cs.primaryContainer.withValues(alpha: 0.3), ); }, @@ -193,8 +287,11 @@ class _RoundTile extends ConsumerWidget { Future _openStatus( BuildContext context, WidgetRef ref, - String action, - ) async { + String action, { + required bool done, + required bool review, + String? roundName, + }) async { final c = coinContext.coin; final configValue = ref.read(votingConfigProvider).value; final chainUrl = (configValue != null && configValue.voteServers.isNotEmpty) @@ -209,7 +306,7 @@ class _RoundTile extends ConsumerWidget { return; } if (!context.mounted) return; - if (action == "done") { + if (done) { await GoRouter.of(context).push("/voting/results", extra: { "roundId": round.roundId, "chainUrl": chainUrl, @@ -224,12 +321,26 @@ class _RoundTile extends ConsumerWidget { }); return; } + if (review) { + // Wallet done but the round is still open on the chain: show the + // vote receipt, not the (not-yet-published) tally. + await GoRouter.of(context).push("/voting/confirmation", extra: { + "roundId": round.roundId, + "roundName": roundName, + "chainUrl": chainUrl, + }); + return; + } final settings = await ref.read(appSettingsProvider.future); await GoRouter.of(context).push("/voting/status", extra: { "roundId": round.roundId, "chainUrl": chainUrl, "pirServerUrl": "", - "voteNodeUrl": settings.voteNodeUrl, + // An unset Vote Node URL defaults to the vote chain server: the same + // REST API serves the commitment tree the vote syncs from. + "voteNodeUrl": settings.voteNodeUrl.isNotEmpty + ? settings.voteNodeUrl + : chainUrl, }); } } diff --git a/lib/pages/voting_proposal.dart b/lib/pages/voting_proposal.dart index cfe99c0b0..40e8dd759 100644 --- a/lib/pages/voting_proposal.dart +++ b/lib/pages/voting_proposal.dart @@ -7,6 +7,8 @@ import 'package:go_router/go_router.dart'; import 'package:zkool/main.dart'; import 'package:zkool/src/rust/api/voting.dart'; import 'package:zkool/store.dart'; +import 'package:zkool/utils.dart'; +import 'package:zkool/widgets/error_display.dart'; /// One parsed proposal option. class _Option { @@ -54,6 +56,7 @@ class VotingProposalPageState extends ConsumerState { String? _roundParamsJson; String? _roundName; int? _snapshotHeight; + BigInt? _votingPower; @override void initState() { @@ -101,7 +104,11 @@ class VotingProposalPageState extends ConsumerState { final nullifierImtRoot = _find(round, "nullifier_imt_root"); if (snapshotHeight is int && ncRoot is String && nullifierImtRoot is String) { _snapshotHeight = snapshotHeight; - _roundName = (_find(round, "round_name") ?? _find(round, "name")) + _votingPower = + await votingEligibleWeight(snapshotHeight: snapshotHeight, c: c); + _roundName = (_find(round, "title") ?? + _find(round, "round_name") ?? + _find(round, "name")) ?.toString() ?? widget.roundId; final settings = await ref.read(appSettingsProvider.future); @@ -118,12 +125,16 @@ class VotingProposalPageState extends ConsumerState { } // Best-effort vote-tree pre-sync so the commit step doesn't wait on it. + // An unset Vote Node URL falls back to the vote chain server: the same + // REST API serves the commitment tree the sync pulls from. final settings = await ref.read(appSettingsProvider.future); - if (settings.voteNodeUrl.isNotEmpty) { + final voteNodeUrl = + settings.voteNodeUrl.isNotEmpty ? settings.voteNodeUrl : widget.chainUrl; + if (voteNodeUrl.isNotEmpty) { try { await votingSyncTree( roundId: widget.roundId, - voteNodeUrl: settings.voteNodeUrl, + voteNodeUrl: voteNodeUrl, c: c, ); } on AnyhowException catch (_) { @@ -158,11 +169,17 @@ class VotingProposalPageState extends ConsumerState { if (id is! int || id < 1 || id > 15) return null; final title = (value['title'] ?? "Proposal $id").toString(); var options = (value['options'] as List? ?? []) - .map((o) { + .asMap() + .entries + .map((entry) { + final o = entry.value; if (o is! Map) return null; return _Option( - id: (o['id'] is int) ? o['id'] as int : 0, - label: (o['label'] ?? o['title'] ?? "Option").toString(), + // vote-sdk option ids come from `index` (omitted = 0 for the + // first option); fall back to the list position. + id: (o['index'] is int) ? o['index'] as int : entry.key, + label: (o['label'] ?? o['short_title'] ?? o['title'] ?? "Option") + .toString(), ); }) .whereType<_Option>() @@ -177,29 +194,19 @@ class VotingProposalPageState extends ConsumerState { return _Proposal(id: id, title: title, options: options); } + Future _persistSafe() async { + try { + await _persist(); + } on AnyhowException catch (e) { + if (mounted) await showException(context, e.message); + } + } + + /// Persists the draft ballot only; durable ballot intents are written by + /// the submission job from these drafts before the cast loop (mirrors + /// vizor), when the round row already exists in the voting DB. Future _persist() async { final c = coinContext.coin; - for (final p in _proposals) { - if (_skipped.contains(p.id)) { - await votingSetBallotIntent( - roundId: widget.roundId, - proposalId: p.id, - skipped: true, - choice: 0, - numOptions: p.options.length, - c: c, - ); - } else if (_choices.containsKey(p.id)) { - await votingSetBallotIntent( - roundId: widget.roundId, - proposalId: p.id, - skipped: false, - choice: _choices[p.id]!, - numOptions: p.options.length, - c: c, - ); - } - } // Draft votes mirror the fork's DraftVote JSON: skipped = choice == num_options. final drafts = _proposals .where((p) => _skipped.contains(p.id) || _choices.containsKey(p.id)) @@ -230,7 +237,7 @@ class VotingProposalPageState extends ConsumerState { ); return Scaffold( - appBar: AppBar(title: Text(widget.roundId)), + appBar: AppBar(title: Text(_roundName ?? widget.roundId)), body: _error != null ? Center(child: Text(_error!)) : _proposals.isEmpty @@ -259,7 +266,7 @@ class VotingProposalPageState extends ConsumerState { _skipped.remove(p.id); _choices[p.id] = v!; }); - await _persist(); + await _persistSafe(); }, )), RadioListTile( @@ -271,7 +278,7 @@ class VotingProposalPageState extends ConsumerState { _choices.remove(p.id); _skipped.add(p.id); }); - await _persist(); + await _persistSafe(); }, ), ], @@ -283,17 +290,34 @@ class VotingProposalPageState extends ConsumerState { bottomNavigationBar: SafeArea( child: Padding( padding: const EdgeInsets.all(12), - child: FilledButton( - onPressed: allAnswered - ? () => GoRouter.of(context).push("/voting/review", extra: { - "roundId": widget.roundId, - "chainUrl": widget.chainUrl, - "roundParamsJson": _roundParamsJson, - "roundName": _roundName, - "snapshotHeight": _snapshotHeight, - }) - : null, - child: const Text("Review answers"), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_snapshotHeight != null) + Text( + "Snapshot height: $_snapshotHeight", + textAlign: TextAlign.center, + ), + if (_votingPower != null) + Text( + "Voting power: ${formatVotingPower(_votingPower!)}", + textAlign: TextAlign.center, + ), + if (_snapshotHeight != null || _votingPower != null) + const SizedBox(height: 8), + FilledButton( + onPressed: allAnswered + ? () => GoRouter.of(context).pushReplacement("/voting/review", extra: { + "roundId": widget.roundId, + "chainUrl": widget.chainUrl, + "roundParamsJson": _roundParamsJson, + "roundName": _roundName, + "snapshotHeight": _snapshotHeight, + }) + : null, + child: const Text("Review answers"), + ), + ], ), ), ), diff --git a/lib/pages/voting_results.dart b/lib/pages/voting_results.dart index 2fa625b18..474f1ee41 100644 --- a/lib/pages/voting_results.dart +++ b/lib/pages/voting_results.dart @@ -29,6 +29,7 @@ class VotingResultsPageState extends ConsumerState { Timer? _pollTimer; String? _error; bool _tallying = false; + bool _zeroFilled = false; Map> _tallies = {}; // proposal id -> option id -> amount @override @@ -47,11 +48,7 @@ class VotingResultsPageState extends ConsumerState { try { final c = coinContext.coin; final session = await ref.read(votingSessionProvider(widget.roundId).future); - final intents = session.intents - .map((i) => i.proposalId) - .toSet() - .toList() - ..sort(); + final intents = session.intents.map((i) => i.proposalId).toSet().toList()..sort(); final drafts = await votingDraftsLoad(roundId: widget.roundId, c: c); if (drafts != null && drafts.isNotEmpty) { for (final d in jsonDecode(drafts) as List) { @@ -76,10 +73,21 @@ class VotingResultsPageState extends ConsumerState { ); } final body = jsonDecode(res.body) as Map; - final status = ((body['status'] ?? body['phase'] ?? "") as String) - .toLowerCase(); + final status = ((body['status'] ?? body['phase'] ?? "") as String).toLowerCase(); _tallying = status == "2" || status == "tallying" || status == "pending"; _tallies = _parseTally(body, intents); + if (_tallies.isEmpty) { + // A closed round with no recorded votes still shows its ballot at + // zero rather than an empty screen. + final roundStatus = await ref.read(votingRoundStatusProvider(widget.roundId, widget.chainUrl).future); + if (roundStatus == "closed") { + final proposals = await ref.read(votingRoundProposalsProvider(widget.roundId, widget.chainUrl).future); + _zeroFilled = true; + _tallies = { + for (final p in proposals) p.id: {for (final o in p.optionLabels.keys) o: 0}, + }; + } + } if (mounted) setState(() {}); if (_tallying) _schedulePoll(); } on AnyhowException catch (e) { @@ -107,16 +115,41 @@ class VotingResultsPageState extends ConsumerState { ? v.toInt() : int.tryParse(v?.toString() ?? ""); - num? toNum(Object? v) => v is num - ? v - : num.tryParse(v?.toString() ?? ""); + num? toNum(Object? v) => v is num ? v : num.tryParse(v?.toString() ?? ""); - int decisionOf(Object? v) => toInt(value(["vote_decision", "voteDecision", "decision", "choice", "index", "option", "option_id", "optionId"])) ?? 0; + const decisionKeys = [ + "vote_decision", + "voteDecision", + "decision", + "choice", + "index", + "option", + "option_id", + "optionId", + ]; + const amountKeys = ["total_value", "totalValue", "amount", "votes", "value"]; - num? amountOf(Object? v) => toNum(value(["total_value", "totalValue", "amount", "votes", "value"])); + int decisionOf(Object? v) { + if (v is! Map) return 0; + for (final k in decisionKeys) { + if (v.containsKey(k)) return toInt(v[k]) ?? 0; + } + return 0; + } + + num? amountOf(Object? v) { + if (v is! Map) return null; + for (final k in amountKeys) { + if (v.containsKey(k)) return toNum(v[k]); + } + return null; + } void addDirect(Object? object, int proposalId) { if (object is! Map) return; + // The vote-sdk emits one row per decision; decision 0 omits the + // decision key (the default), so a missing key maps to 0 — it is NOT + // an aggregate total row. final d = decisionOf(object); final a = amountOf(object); if (a != null) { @@ -170,78 +203,99 @@ class VotingResultsPageState extends ConsumerState { final pinlock = ref.watch(lifecycleProvider); if (pinlock.value ?? false) return PinLock(); + // Proposal titles and option labels, keyed by proposal id (not list + // position) — decision ids are the vote-sdk option ids. + final proposalsAsync = ref.watch( + votingRoundProposalsProvider(widget.roundId, widget.chainUrl), + ); + final proposals = { + for (final p in (proposalsAsync.value ?? const [])) p.id: p, + }; + + final roundTitle = (ref.watch(votingRoundTitleProvider(widget.roundId, widget.chainUrl)).value ?? widget.roundId); + return Scaffold( - appBar: AppBar(title: Text("${widget.roundId} results")), + appBar: AppBar(title: Text(roundTitle)), body: _error != null ? Center(child: Text(_error!)) : _tallies.isEmpty ? Center( child: Text( - _tallying - ? "Results pending..." - : "No tally data for this round", + _tallying ? "Results pending..." : "No tally data for this round", ), ) - : ListView.builder( - itemCount: _tallies.length, - itemBuilder: (context, i) { - final pid = _tallies.keys.elementAt(i); - final tally = _tallies[pid]!; - final total = tally.values.fold(0, (a, b) => a + b); - final winner = tally.entries.reduce( - (a, b) => a.value >= b.value ? a : b, - ); - return Card( - child: Padding( + : Column( + children: [ + if (_zeroFilled) + Padding( padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text("Proposal $pid", - style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - ...tally.entries.map((e) { - final fraction = total == 0 - ? 0.0 - : e.value / total; - final winning = e.key == winner.key; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - SizedBox( - width: 80, - child: Text( - "Option ${e.key + 1}", - style: TextStyle( - fontWeight: winning - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ), - Expanded( - child: LinearProgressIndicator( - value: fraction.clamp(0.0, 1.0), - minHeight: 8, - ), - ), - SizedBox( - width: 90, - child: Text( - "${(fraction * 100).toStringAsFixed(1)}%", - textAlign: TextAlign.end, - ), - ), - ], - ), - ); - }), - ], + child: Text( + "No votes were recorded for this round", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), - ); - }, + Expanded( + child: ListView.builder( + itemCount: _tallies.length, + itemBuilder: (context, i) { + final pid = _tallies.keys.elementAt(i); + final tally = _tallies[pid]!; + final total = tally.values.fold(0, (a, b) => a + b); + final winner = tally.entries.reduce( + (a, b) => a.value >= b.value ? a : b, + ); + final proposal = proposals[pid]; + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(proposal?.title ?? "Proposal $pid", style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ...tally.entries.map((e) { + final fraction = total == 0 ? 0.0 : e.value / total; + final winning = e.key == winner.key; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox( + width: 80, + child: Text( + proposal?.optionLabels[e.key] ?? "Option ${e.key}", + style: TextStyle( + fontWeight: winning ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + Expanded( + child: LinearProgressIndicator( + value: fraction.clamp(0.0, 1.0), + minHeight: 8, + ), + ), + SizedBox( + width: 90, + child: Text( + "${(fraction * 100).toStringAsFixed(1)}%", + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + }), + ], + ), + ), + ); + }, + ), + ), + ], ), ); } diff --git a/lib/pages/voting_review.dart b/lib/pages/voting_review.dart index 8737df880..7b1f50338 100644 --- a/lib/pages/voting_review.dart +++ b/lib/pages/voting_review.dart @@ -32,6 +32,9 @@ class VotingReviewPage extends ConsumerStatefulWidget { class VotingReviewPageState extends ConsumerState { List> _drafts = []; + /// proposal id -> option ids and their labels, from the chain round status + /// (the drafts store only the choice index). + final Map> _optionsByProposal = {}; String? _error; @override @@ -49,16 +52,65 @@ class VotingReviewPageState extends ConsumerState { .map((d) => d as Map) .toList(); } + await _loadOptions(); if (mounted) setState(() {}); } on AnyhowException catch (e) { if (mounted) setState(() => _error = e.message); } } + /// Best-effort: fetches the proposal options from the chain so the review + /// can show option labels instead of indices. On failure the review falls + /// back to "Option N". + Future _loadOptions() async { + try { + final c = coinContext.coin; + final res = await votechainRoundStatus( + baseUrl: widget.chainUrl, + roundId: widget.roundId, + c: c, + ); + if (res.statusCode < 200 || res.statusCode >= 300) return; + final body = jsonDecode(res.body) as Map; + final round = body['round'] as Map? ?? {}; + final proposals = round['proposals'] as List? ?? []; + for (final p in proposals) { + if (p is! Map) continue; + final pid = p['id']; + if (pid is! int || pid < 1) continue; + var options = (p['options'] as List? ?? []) + .asMap() + .entries + .map((entry) { + final o = entry.value; + if (o is! Map) return null; + final id = (o['index'] is int) ? o['index'] as int : entry.key; + final label = + (o['label'] ?? o['short_title'] ?? o['title'] ?? "Option") + .toString(); + return _ReviewOption(id, label); + }) + .whereType<_ReviewOption>() + .toList(); + if (options.isEmpty) { + // Vote-sdk default: Yes/No when options are missing. + options = const [_ReviewOption(0, "Yes"), _ReviewOption(1, "No")]; + } + _optionsByProposal[pid] = options; + } + } on AnyhowException { + // Best-effort only; _answerLabel falls back to indices. + } + } + String _answerLabel(Map draft) { + final proposalId = draft['proposal_id'] as int? ?? 0; final choice = draft['choice'] as int? ?? 0; final numOptions = draft['num_options'] as int? ?? 2; if (choice == numOptions) return "Skipped"; + for (final option in _optionsByProposal[proposalId] ?? const []) { + if (option.id == choice) return option.label; + } return "Option ${choice + 1}"; } @@ -99,7 +151,7 @@ class VotingReviewPageState extends ConsumerState { : () async { final settings = await ref.read(appSettingsProvider.future); if (!context.mounted) return; - await GoRouter.of(context).push("/voting/status", extra: { + await GoRouter.of(context).pushReplacement("/voting/status", extra: { "roundId": widget.roundId, "chainUrl": widget.chainUrl, "pirServerUrl": "", @@ -116,3 +168,10 @@ class VotingReviewPageState extends ConsumerState { ); } } + +class _ReviewOption { + final int id; + final String label; + + const _ReviewOption(this.id, this.label,); +} diff --git a/lib/pages/voting_status.dart b/lib/pages/voting_status.dart index fb5c738ca..c489394e2 100644 --- a/lib/pages/voting_status.dart +++ b/lib/pages/voting_status.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:zkool/main.dart'; +import 'package:zkool/services/votechain_backoff.dart'; import 'package:zkool/src/rust/api/voting.dart'; import 'package:zkool/store.dart'; import 'package:zkool/utils.dart'; @@ -73,8 +74,8 @@ class VotingStatusPageState extends ConsumerState { ); } - String _stageLabel(String stage) { - switch (stage) { + String _stageLabel(VotingSubmissionJobState job) { + switch (job.stage) { case "preparing": return "Preparing delegation bundle"; case "proving": @@ -83,8 +84,15 @@ class VotingStatusPageState extends ConsumerState { return "Submitting delegation to the vote chain"; case "confirming": return "Waiting for confirmation"; + case "voting": + return "Casting votes"; + case "shares": + return "Submitting shares"; + case "retrying": + return "Retrying in ${job.retryInSeconds ?? 0}s " + "(attempt ${job.retryAttempt ?? 1} of $voteChainMaxAutoRetries)"; case "done": - return "Delegation confirmed"; + return job.doneLabel ?? "Submission complete"; case "error": return "Submission failed"; default: @@ -98,10 +106,44 @@ class VotingStatusPageState extends ConsumerState { if (pinlock.value ?? false) return PinLock(); final job = ref.watch(votingSubmissionJobProvider(widget.roundId)); - final running = job.stage != "done" && job.stage != "error"; + // Confirmed vote txs (per proposal), shown as evidence on the done state. + // A vote confirmed via commitment-tree recovery has no tx hash — it still + // counts as confirmed evidence. + final confirmedVotes = (ref + .watch(votingSessionProvider(widget.roundId)) + .value + ?.recovery + ?.votes ?? + const []) + .where((v) => v.phase == "confirmed") + .toList(); + // Human-readable ballot evidence: proposal titles and option labels, + // keyed by proposal id (not list position). + final proposals = { + for (final p in (ref + .watch(votingRoundProposalsProvider( + widget.roundId, + widget.chainUrl, + )) + .value ?? + const [])) + p.id: p, + }; + + String voteLabel(VotingVoteRecovery v) { + final proposal = proposals[v.proposalId]; + final title = proposal?.title ?? "Proposal ${v.proposalId}"; + // Option ids are the vote-sdk `index` values (omitted = 0 for the + // first); never render 1-based list positions. + final choice = proposal?.optionLabels[v.choice] ?? "Option ${v.choice}"; + return "$title — $choice"; + } return PopScope( - canPop: !running, + // All progress is durable and the resume plan re-enters any incomplete + // step on the next run, so leaving is safe in every stage except the + // two heavy ZK stages, where aborting mid-proof just wastes the work. + canPop: job.stage != "preparing" && job.stage != "proving", onPopInvokedWithResult: (didPop, _) async { if (didPop) return; if (_handlingLeave) return; @@ -127,8 +169,19 @@ class VotingStatusPageState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (widget.roundName != null && + widget.roundName!.isNotEmpty && + widget.roundName != widget.roundId) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + widget.roundName!, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ), Text( - _stageLabel(job.stage), + _stageLabel(job), style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), @@ -136,7 +189,9 @@ class VotingStatusPageState extends ConsumerState { LinearProgressIndicator( value: job.stage == "done" ? 1 - : job.stage == "proving" || job.stage == "confirming" + : job.stage == "proving" || + job.stage == "confirming" || + job.stage == "shares" ? null : job.progress, minHeight: 6, @@ -149,9 +204,47 @@ class VotingStatusPageState extends ConsumerState { textAlign: TextAlign.center, ), ), + if (job.eligibleWeightZatoshi != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + "Voting power: ${formatVotingPower(job.eligibleWeightZatoshi!)}", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + if (job.stage == "done" && job.txHash != null) ...[ + const SizedBox(height: 12), + SelectableText( + "Transaction: ${job.txHash}", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + if (job.confirmHeight != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + "Included in block ${job.confirmHeight}", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + for (final v in confirmedVotes) + Padding( + padding: const EdgeInsets.only(top: 4), + child: SelectableText( + v.txHash != null && v.txHash!.isNotEmpty + ? "${voteLabel(v)}\n${v.txHash} · tree ${v.vcTreePosition}" + : "${voteLabel(v)}\ntree ${v.vcTreePosition} · " + "verified in the commitment tree", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), const SizedBox(height: 24), if (job.stage == "error") ...[ - Text( + SelectableText( job.error ?? "Unknown error", style: TextStyle( color: Theme.of(context).colorScheme.error, @@ -172,7 +265,11 @@ class VotingStatusPageState extends ConsumerState { FilledButton( onPressed: () => GoRouter.of(context).pushReplacement( "/voting/confirmation", - extra: {"roundId": widget.roundId}, + extra: { + "roundId": widget.roundId, + "roundName": widget.roundName, + "chainUrl": widget.chainUrl, + }, ), child: const Text("Done"), ), diff --git a/lib/router.dart b/lib/router.dart index b685bfa30..189d1e8a2 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -178,7 +178,11 @@ GoRouter router(bool disclaimerAccepted, bool recoveryMode) => GoRouter( path: '/voting/confirmation', builder: (context, state) { final args = state.extra as Map; - return VotingConfirmationPage(roundId: args['roundId'] as String); + return VotingConfirmationPage( + roundId: args['roundId'] as String, + roundName: args['roundName'] as String?, + chainUrl: args['chainUrl'] as String? ?? "", + ); }, ), GoRoute( diff --git a/lib/services/votechain_backoff.dart b/lib/services/votechain_backoff.dart new file mode 100644 index 000000000..6dd6c5b62 --- /dev/null +++ b/lib/services/votechain_backoff.dart @@ -0,0 +1,18 @@ +/// Delay before the next automatic retry of a transiently failed voting run. +/// +/// Deterministic (unlike the synchronizer's randomized backoff) so tests and +/// the status page can predict the retry schedule: 30 s, 60 s, then 120 s for +/// any later attempt. +Duration voteChainRetryDelay(int attempt) { + switch (attempt) { + case 1: + return const Duration(seconds: 30); + case 2: + return const Duration(seconds: 60); + default: + return const Duration(seconds: 120); + } +} + +/// Maximum automatic retries for a transiently failed voting run. +const voteChainMaxAutoRetries = 3; diff --git a/lib/services/votechain_classify.dart b/lib/services/votechain_classify.dart new file mode 100644 index 000000000..691878461 --- /dev/null +++ b/lib/services/votechain_classify.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +/// How a 422 (or 4xx rejection body) from the vote chain should be handled. +enum ChainRejectionKind { + /// The chain rejected the submission because a nullifier it carries was + /// already spent — for a resubmission of identical wire bytes this means + /// the original transaction committed successfully (the client just never + /// learned its hash). + duplicateNullifier, + + /// Any other deterministic rejection; permanent, do not retry blindly. + other, +} + +/// Classifies a vote-chain rejection body. +/// +/// The vote-sdk surfaces duplicate nullifiers through CheckTx logs like +/// `nullifier already spent`; every other rejection is surfaced as-is. +ChainRejectionKind classifyVoteChainRejection(String body) { + final lower = body.toLowerCase(); + if (lower.contains("nullifier") && + (lower.contains("spent") || + lower.contains("spend") || + lower.contains("already"))) { + return ChainRejectionKind.duplicateNullifier; + } + return ChainRejectionKind.other; +} + +/// Extracts a tx hash from a vote-chain response body. +/// +/// Accepted forms: the `tx_hash` field of a JSON `{tx_hash, code, log}` +/// envelope, and the `tx_hash=...` fragment of the 502 +/// "broadcast outcome unknown after retries" message. +String? txHashFromVoteChainBody(String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final hash = decoded['tx_hash']; + if (hash is String && hash.isNotEmpty) return hash; + } + } on FormatException { + // Fall through to the substring form. + } + final match = RegExp(r"tx_hash=([0-9A-Fa-f]+)").firstMatch(body); + return match?.group(1); +} diff --git a/lib/services/votechain_confirmation.dart b/lib/services/votechain_confirmation.dart new file mode 100644 index 000000000..4f44330e7 --- /dev/null +++ b/lib/services/votechain_confirmation.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +/// Parsed proof of block inclusion from a 200 GET tx/{hash} response. +class VoteChainTxConfirmation { + /// JSON-encoded events array from the response (may be `"[]"`). + final String eventsJson; + + /// Block height at which the transaction was included; always > 0. + final int height; + + const VoteChainTxConfirmation({ + required this.eventsJson, + required this.height, + }); +} + +/// Parses a 200 body from the vote chain tx confirmation endpoint. +/// +/// Returns null when the body does not prove block inclusion: malformed +/// JSON, a non-map body, a missing `height`, or `height <= 0`. Callers treat +/// null as "not confirmed yet" and keep polling. +VoteChainTxConfirmation? parseVoteChainTxConfirmation(String body) { + final dynamic decoded; + try { + decoded = jsonDecode(body); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final rawHeight = decoded['height']; + // The chain reports height as a JSON string ("7163319"), sometimes a number. + final height = rawHeight is int + ? rawHeight + : rawHeight is num + ? rawHeight.toInt() + : rawHeight is String + ? int.tryParse(rawHeight) ?? 0 + : 0; + if (height <= 0) return null; + return VoteChainTxConfirmation( + eventsJson: jsonEncode(decoded['events'] ?? const []), + height: height, + ); +} + +/// Extracts `{code, log}` from a 4xx/5xx chain response body for user-facing +/// error messages. Falls back to the raw body when it is not the +/// `{tx_hash, code, log}` envelope. +({int code, String log}) parseVoteChainRejection(String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final code = decoded['code'] is int ? decoded['code'] as int : -1; + final log = decoded['log'] is String ? decoded['log'] as String : ''; + return (code: code, log: log); + } + } on FormatException { + // Fall through to the raw-body form. + } + return (code: -1, log: body); +} diff --git a/lib/services/votechain_failover.dart b/lib/services/votechain_failover.dart new file mode 100644 index 000000000..901a25876 --- /dev/null +++ b/lib/services/votechain_failover.dart @@ -0,0 +1,132 @@ +import 'package:zkool/src/rust/api/voting.dart'; + +import 'votechain_classify.dart'; + +/// Raised when every candidate vote-chain server failed with a transport +/// error or an unresolved 5xx — a transient, retryable condition from the +/// caller's perspective. +class TransientVoteChainException implements Exception { + final String message; + + TransientVoteChainException(this.message); + + @override + String toString() => message; +} + +/// Raised when a re-broadcast was rejected because a nullifier it carries was +/// already spent on-chain: the original submission committed successfully but +/// its tx hash was never recorded locally. The caller reconciles the +/// confirmation from the commitment tree instead of failing the run. +class DuplicateNullifierOnResubmitException implements Exception { + final String message; + + DuplicateNullifierOnResubmitException(this.message); + + @override + String toString() => message; +} + +/// A per-URL vote-chain call: transport failures throw, completed HTTP +/// responses (any status) are returned as `VotingChainResponse`. +typedef VoteChainCall = Future Function(String baseUrl); + +/// Rotates a vote-chain call across the configured server list. +/// +/// Policy per candidate URL, in order: +/// - transport error → next candidate; +/// - 2xx / 4xx → final answer (404 "not found" and 422 "rejected" are +/// answers, not failures); +/// - 503 → honor `Retry-After` and retry the same URL up to `max503Retries` +/// times (the vote-sdk warms its verifier cache behind a 503 gate); +/// - 502 → an answer only when the body carries a tx hash (the vote-sdk's +/// "broadcast outcome unknown; tx_hash=..." envelope); otherwise next; +/// - any other 5xx → next candidate. +/// +/// The last URL that produced an answer is remembered per round and tried +/// first on the next run, so a recovered server is found without replaying +/// the outage. All candidates failing raises [TransientVoteChainException]. +class VoteChainFailover { + final List allServers; + + /// Injectable sleep (tests pass a no-op). + final Future Function(Duration duration) delay; + + final Map _lastWorking = {}; + + VoteChainFailover({ + this.allServers = const [], + Future Function(Duration duration)? delay, + }) : delay = delay ?? Future.delayed; + + /// Runs [call] with failover across `baseUrls` (caller's preferred URL + /// first) plus [allServers]. + Future run({ + required List baseUrls, + required String roundId, + required VoteChainCall call, + int max503Retries = 1, + }) async { + Object? lastError; + for (final url in orderedCandidates(baseUrls, roundId)) { + var retries = 0; + while (true) { + final VotingChainResponse res; + try { + res = await call(url); + } catch (e) { + lastError = e; + break; // transport failure: this server did not answer + } + final status = res.statusCode; + if (status >= 200 && status < 300) { + _lastWorking[roundId] = url; + return res; + } + if (status >= 400 && status < 500) { + // A definitive answer (404 not found, 422 rejected, 409 conflict). + _lastWorking[roundId] = url; + return res; + } + if (status == 502) { + if (txHashFromVoteChainBody(res.body) != null) { + _lastWorking[roundId] = url; + return res; + } + lastError = Exception('vote chain 502 from $url: ${res.body}'); + break; + } + if (status == 503 && retries < max503Retries) { + final wait = (res.retryAfterSecs?.toInt() ?? 2).clamp(1, 120); + await delay(Duration(seconds: wait)); + retries++; + continue; + } + lastError = Exception('vote chain HTTP $status from $url: ${res.body}'); + break; + } + } + throw TransientVoteChainException( + 'all vote chain servers failed (${orderedCandidates(baseUrls, roundId).length} tried): $lastError', + ); + } + + /// Candidate order: the round's last-working URL (when configured), the + /// caller's list, then the remaining configured servers; deduplicated. + List orderedCandidates(List baseUrls, String roundId) { + final ordered = []; + void add(String? url) { + if (url == null || url.isEmpty || ordered.contains(url)) return; + ordered.add(url); + } + + add(_lastWorking[roundId]); + for (final url in baseUrls) { + add(url); + } + for (final url in allServers) { + add(url); + } + return ordered; + } +} diff --git a/lib/settings.dart b/lib/settings.dart index 807eb9a3a..5163c8e62 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -13,9 +13,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:zkool/router.dart'; -import 'package:zkool/src/rust/api/coin.dart'; import 'package:zkool/src/rust/api/network.dart' show isValidNymUrl; -import 'package:zkool/src/rust/api/db.dart'; import 'package:zkool/src/rust/api/init.dart'; import 'package:zkool/src/rust/api/sapling.dart'; import 'package:zkool/src/rust/api/sync.dart'; @@ -35,7 +33,6 @@ class SettingsPage extends ConsumerStatefulWidget { } class SettingsPageState extends ConsumerState with RouteAware { - late Coin c = coinContext.coin; AppSettings? settings; @override @@ -61,39 +58,20 @@ class SettingsPageState extends ConsumerState with RouteAware { if (settings == null) return blank(context); return SettingsForm( settings!, - onChanged: (settings) async { - final prefs = SharedPreferencesAsync(); - await prefs.setString("database", settings.dbName); - await putProp(key: "is_light_node", value: settings.isLightNode.toString(), c: c); - await putProp(key: "lwd", value: settings.lwd, c: c); - await putProp(key: "block_explorer", value: settings.blockExplorer, c: c); - await putProp(key: "actions_per_sync", value: settings.actionsPerSync, c: c); - await putProp(key: "sync_interval", value: settings.syncInterval, c: c); - await prefs.setBool("pin_lock", settings.needPin); - await prefs.setBool("offline", settings.offline); - await prefs.setInt("transport", settings.transport); - await putProp(key: "proxy", value: settings.proxy, c: c); - await prefs.setBool("get_fx", settings.getFx); - await prefs.setString("coingecko", settings.coingecko); - await putProp(key: "qr_enabled", value: settings.qrSettings.enabled.toString(), c: c); - await putProp(key: "qr_size", value: settings.qrSettings.size.toString(), c: c); - await putProp(key: "qr_ecLevel", value: settings.qrSettings.ecLevel.toString(), c: c); - await putProp(key: "qr_delay", value: settings.qrSettings.delay.toString(), c: c); - await putProp(key: "qr_repair", value: settings.qrSettings.repair.toString(), c: c); - c = c.setLwd(url: settings.lwd, serverType: settings.isLightNode ? 0 : 1); - c = c.setTransport(transport: settings.transport); - c = c.setProxy(proxy: settings.proxy); - await prefs.setBool("vault", settings.vault); - await prefs.setBool("expert_mode", settings.expertMode); - await prefs.setString("palette_name", settings.paletteName); - await prefs.setBool("dark_mode", settings.darkMode); - await putProp(key: "currency", value: settings.currency, c: c); - coinContext.set(coin: c); - ref.read(priceProvider.notifier).setAutoFetchFx(settings.getFx, settings.coingecko, settings.currency); - ref.invalidate(appSettingsProvider); - }, + // Keep a snapshot of the latest edits; the changes are applied only + // when leaving this page (see didPop), matching the sub-page pattern. + onChanged: (settings) => this.settings = settings, ); } + + @override + void didPop() { + super.didPop(); + final s = settings; + if (s != null) { + ref.read(appSettingsProvider.notifier).save(s); + } + } } class SettingsForm extends ConsumerStatefulWidget { @@ -193,9 +171,12 @@ class SettingsFormState extends ConsumerState { Tooltip( message: "Network transport: connect directly, through the embedded " "Tor (Arti) client, through the Nym mixnet, or via an external proxy", - child: Row( - children: [ - SegmentedButton( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: SegmentedButton( segments: const [ ButtonSegment(value: 0, label: Text("Direct")), ButtonSegment(value: 1, label: Text("Tor")), @@ -205,7 +186,7 @@ class SettingsFormState extends ConsumerState { selected: {isNymServer ? 0 : settings.transport}, onSelectionChanged: isNymServer ? null : onChangedTransport, ), - ], + ), ), ), if (isNymServer) @@ -563,8 +544,9 @@ class SettingsFormState extends ConsumerState { Future _fetchVotingConfig(BuildContext context) async { try { - final config = - await ref.read(votingConfigProvider.notifier).resolve(); + final config = await ref + .read(votingConfigProvider.notifier) + .resolve(source: settings.votingConfigUrl); if (!context.mounted) return; if (config == null) { await showMessage( diff --git a/lib/src/rust/api/sapling.dart b/lib/src/rust/api/sapling.dart index dfc524efd..72388e983 100644 --- a/lib/src/rust/api/sapling.dart +++ b/lib/src/rust/api/sapling.dart @@ -8,7 +8,10 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; // These functions are ignored because they are not marked as `pub`: `download_and_verify`, `resolve_params_dir`, `set_sapling_params_dir` -/// Check whether Sapling parameters are already on disk. +/// Check whether Sapling parameters are available. +/// +/// With `bundled-sapling-params` they are compiled into the binary and always +/// considered available. Otherwise checks whether they are on disk. SaplingParamsStatus checkSaplingParams() => RustLib.instance.api.crateApiSaplingCheckSaplingParams(); @@ -16,6 +19,8 @@ SaplingParamsStatus checkSaplingParams() => /// /// Verifies file size and Blake2b hash upon download. /// Safe to call even if they are already downloaded (no-op if valid). +/// With `bundled-sapling-params` the parameters are compiled into the binary +/// and this is a no-op. Future downloadSaplingParams() => RustLib.instance.api.crateApiSaplingDownloadSaplingParams(); diff --git a/lib/src/rust/api/voting.dart b/lib/src/rust/api/voting.dart index 108e8b8a4..a3cd66111 100644 --- a/lib/src/rust/api/voting.dart +++ b/lib/src/rust/api/voting.dart @@ -11,7 +11,7 @@ part 'voting.freezed.dart'; // These functions are ignored because they are not marked as `pub`: `config_switch_kind_string`, `fork_network_string`, `from_resolved`, `prepare_bundle`, `to_fork`, `votechain_proxy` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `VotingShareDelivery` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` /// Creates and persists a fresh app-owned voting hotkey (hex stored secret). Future votingHotkeyCreate({required Coin c}) => @@ -163,6 +163,15 @@ Future votingSetBallotIntent( numOptions: numOptions, c: c); +/// Returns the quantized voting weight (zatoshi) for the account's eligible +/// shielded notes at `snapshot_height`, computed with the same canonical +/// bundle planning as the delegation prepare step — but from the local DB +/// only (no witnesses, no tree state). Shown pre-submission as an estimate. +Future votingEligibleWeight( + {required int snapshotHeight, required Coin c}) => + RustLib.instance.api.crateApiVotingVotingEligibleWeight( + snapshotHeight: snapshotHeight, c: c); + /// Persists the draft ballot for a round (props table, wallet-scoped). Future votingDraftsSave( {required String roundId, @@ -302,9 +311,33 @@ Future votingSyncTree( RustLib.instance.api.crateApiVotingVotingSyncTree( roundId: roundId, voteNodeUrl: voteNodeUrl, c: c); -/// Computes the share tracking plan for a round: summary counts, next poll -/// delay, last-moment flag, and freshly planned submissions (with local -/// entropy) for the unconfirmed shares. +/// Enumerates the share payloads of the round's confirmed votes — the +/// first-pass submission source. +Future> votingSharePayloads( + {required String roundId, required Coin c}) => + RustLib.instance.api + .crateApiVotingVotingSharePayloads(roundId: roundId, c: c); + +/// Count-based share submission plans (submitAt + target servers per share), +/// mirroring vizor's `planShareSubmissions`: policy-sized CSPRNG entropy +/// drawn per call, timing from the round's ceremony start / vote end. +Future> votingSharePlans( + {required int shareCount, + required List serverUrls, + required BigInt now, + required BigInt voteEnd, + required BigInt ceremonyStart, + required bool singleShare, + required Coin c}) => + RustLib.instance.api.crateApiVotingVotingSharePlans( + shareCount: shareCount, + serverUrls: serverUrls, + now: now, + voteEnd: voteEnd, + ceremonyStart: ceremonyStart, + singleShare: singleShare, + c: c); + Future votingSharePlan( {required String roundId, required BigInt now, @@ -438,6 +471,80 @@ Future votingConfirm( eventsJson: eventsJson, c: c); +/// Hex-encoded vote commitment leaf value for one committed vote, used to +/// locate the vote's commitment-tree leaf when the tx hash is unknown. +Future votingVoteCommitmentHex( + {required String roundId, + required int bundleIndex, + required int proposalId, + required Coin c}) => + RustLib.instance.api.crateApiVotingVotingVoteCommitmentHex( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + c: c); + +/// Hex-encoded cast-vote VAN output commitment for one committed vote (the +/// commitment-tree leaf appended immediately before the vote commitment). +Future votingVoteVanCommitmentHex( + {required String roundId, + required int bundleIndex, + required int proposalId, + required Coin c}) => + RustLib.instance.api.crateApiVotingVotingVoteVanCommitmentHex( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + c: c); + +/// Hex-encoded delegation VAN commitment (`gov_comm`) for a bundle, or `None` +/// when it was never persisted. Used to locate the delegation's tree leaf. +Future votingDelegationVanCommitmentHex( + {required String roundId, required int bundleIndex, required Coin c}) => + RustLib.instance.api.crateApiVotingVotingDelegationVanCommitmentHex( + roundId: roundId, bundleIndex: bundleIndex, c: c); + +/// Scans the round's commitment tree for a leaf matching `target_hex` and +/// returns its global position, or `None` when absent. +Future votingTreeFindLeaf( + {required String roundId, + required String nodeUrl, + required String targetHex}) => + RustLib.instance.api.crateApiVotingVotingTreeFindLeaf( + roundId: roundId, nodeUrl: nodeUrl, targetHex: targetHex); + +/// Records a cast-vote confirmation whose evidence came from a +/// commitment-tree scan (no tx hash available). The vote's phase becomes +/// Confirmed so the resume plan proceeds to share submission. +Future votingRecoverConfirmVoteFromTree( + {required String roundId, + required int bundleIndex, + required int proposalId, + required BigInt vcTreePosition, + int? vanLeafPosition, + required Coin c}) => + RustLib.instance.api.crateApiVotingVotingRecoverConfirmVoteFromTree( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + vcTreePosition: vcTreePosition, + vanLeafPosition: vanLeafPosition, + c: c); + +/// Records a delegation confirmation recovered from a commitment-tree scan +/// (no tx hash available). The bundle's phase becomes Confirmed so voting can +/// proceed. +Future votingRecoverConfirmDelegationFromTree( + {required String roundId, + required int bundleIndex, + required int vanLeafPosition, + required Coin c}) => + RustLib.instance.api.crateApiVotingVotingRecoverConfirmDelegationFromTree( + roundId: roundId, + bundleIndex: bundleIndex, + vanLeafPosition: vanLeafPosition, + c: c); + /// Lists rounds persisted in the voting DB for the current wallet. Future> votingRounds({required Coin c}) => RustLib.instance.api.crateApiVotingVotingRounds(c: c); @@ -463,12 +570,29 @@ Future votingRecoveryClear({required String roundId, required Coin c}) => RustLib.instance.api .crateApiVotingVotingRecoveryClear(roundId: roundId, c: c); +/// Resets process-local vote-tree cache and clears unsigned delegation setup +/// fields for a round (the fork's recovery when a restart after +/// `build_governance_pczt` persisted `pczt_sighash` makes re-setup refuse to +/// overwrite it). Submitted bundles, imported capabilities, and bundles with +/// persisted Keystone signatures are preserved. +Future votingResetSessionState( + {required String roundId, required Coin c}) => + RustLib.instance.api + .crateApiVotingVotingResetSessionState(roundId: roundId, c: c); + /// Returns the persisted ballot intents for a round, sorted by proposal id. Future> votingBallotIntents( {required String roundId, required Coin c}) => RustLib.instance.api .crateApiVotingVotingBallotIntents(roundId: roundId, c: c); +/// Loads sessions for many rounds in a single Rust call that holds ONE pool +/// connection (the per-round entry points above would need one connection per +/// round and stall the pool once the page has many rounds). +Future> votingSessions( + {required List roundIds, required Coin c}) => + RustLib.instance.api.crateApiVotingVotingSessions(roundIds: roundIds, c: c); + /// Lists rounds from the vote server (`{ "rounds": [...] }`). Future votechainListRounds( {required String baseUrl, required Coin c}) => @@ -555,6 +679,7 @@ sealed class VotingChainResponse with _$VotingChainResponse { const factory VotingChainResponse({ required int statusCode, required String body, + BigInt? retryAfterSecs, }) = _VotingChainResponse; } @@ -781,6 +906,18 @@ sealed class VotingRoundRecovery with _$VotingRoundRecovery { }) = _VotingRoundRecovery; } +/// One round's full session state: resume plan, recovery snapshot, and +/// ballot intents — loaded together under a single pool connection. +@freezed +sealed class VotingRoundSession with _$VotingRoundSession { + const factory VotingRoundSession({ + required String roundId, + required VotingRoundPlan plan, + required VotingRoundRecovery recovery, + required List intents, + }) = _VotingRoundSession; +} + /// Endpoint advertised by a voting service config. @freezed sealed class VotingServiceEndpoint with _$VotingServiceEndpoint { @@ -842,6 +979,23 @@ sealed class VotingSharePlanItem with _$VotingSharePlanItem { }) = _VotingSharePlanItem; } +/// Computes the share tracking plan for a round: summary counts, next poll +/// delay, last-moment flag, and freshly planned submissions (with local +/// entropy) for the unconfirmed shares. +/// One share of a confirmed vote pending helper submission. First-pass +/// submission must enumerate from the confirmed votes' recovery bundles — +/// the `voting_share_delegations` rows only exist after a submission +/// records them. +@freezed +sealed class VotingShareSubmissionPayload with _$VotingShareSubmissionPayload { + const factory VotingShareSubmissionPayload({ + required int bundleIndex, + required int proposalId, + required int shareIndex, + BigInt? vcTreePosition, + }) = _VotingShareSubmissionPayload; +} + /// Share tracking summary, one-to-one with the fork's `ShareTrackingSummary`. @freezed sealed class VotingShareTrackingSummary with _$VotingShareTrackingSummary { @@ -882,6 +1036,15 @@ sealed class VotingSignedVoteCommitment with _$VotingSignedVoteCommitment { }) = _VotingSignedVoteCommitment; } +/// Confirmation evidence recovered from a commitment-tree scan. +@freezed +sealed class VotingTreeVoteConfirmation with _$VotingTreeVoteConfirmation { + const factory VotingTreeVoteConfirmation({ + required BigInt vcTreePosition, + int? vanLeafPosition, + }) = _VotingTreeVoteConfirmation; +} + @freezed sealed class VotingVanWitness with _$VotingVanWitness { const factory VotingVanWitness({ diff --git a/lib/src/rust/api/voting.freezed.dart b/lib/src/rust/api/voting.freezed.dart index 626558658..852e88a79 100644 --- a/lib/src/rust/api/voting.freezed.dart +++ b/lib/src/rust/api/voting.freezed.dart @@ -332,6 +332,7 @@ class __$VotingBallotIntentCopyWithImpl<$Res> mixin _$VotingChainResponse { int get statusCode; String get body; + BigInt? get retryAfterSecs; /// Create a copy of VotingChainResponse /// with the given fields replaced by the non-null parameter values. @@ -348,15 +349,18 @@ mixin _$VotingChainResponse { other is VotingChainResponse && (identical(other.statusCode, statusCode) || other.statusCode == statusCode) && - (identical(other.body, body) || other.body == body)); + (identical(other.body, body) || other.body == body) && + (identical(other.retryAfterSecs, retryAfterSecs) || + other.retryAfterSecs == retryAfterSecs)); } @override - int get hashCode => Object.hash(runtimeType, statusCode, body); + int get hashCode => + Object.hash(runtimeType, statusCode, body, retryAfterSecs); @override String toString() { - return 'VotingChainResponse(statusCode: $statusCode, body: $body)'; + return 'VotingChainResponse(statusCode: $statusCode, body: $body, retryAfterSecs: $retryAfterSecs)'; } } @@ -366,7 +370,7 @@ abstract mixin class $VotingChainResponseCopyWith<$Res> { VotingChainResponse value, $Res Function(VotingChainResponse) _then) = _$VotingChainResponseCopyWithImpl; @useResult - $Res call({int statusCode, String body}); + $Res call({int statusCode, String body, BigInt? retryAfterSecs}); } /// @nodoc @@ -384,6 +388,7 @@ class _$VotingChainResponseCopyWithImpl<$Res> $Res call({ Object? statusCode = null, Object? body = null, + Object? retryAfterSecs = freezed, }) { return _then(_self.copyWith( statusCode: null == statusCode @@ -394,6 +399,10 @@ class _$VotingChainResponseCopyWithImpl<$Res> ? _self.body : body // ignore: cast_nullable_to_non_nullable as String, + retryAfterSecs: freezed == retryAfterSecs + ? _self.retryAfterSecs + : retryAfterSecs // ignore: cast_nullable_to_non_nullable + as BigInt?, )); } } @@ -489,13 +498,14 @@ extension VotingChainResponsePatterns on VotingChainResponse { @optionalTypeArgs TResult maybeWhen( - TResult Function(int statusCode, String body)? $default, { + TResult Function(int statusCode, String body, BigInt? retryAfterSecs)? + $default, { required TResult orElse(), }) { final _that = this; switch (_that) { case _VotingChainResponse() when $default != null: - return $default(_that.statusCode, _that.body); + return $default(_that.statusCode, _that.body, _that.retryAfterSecs); case _: return orElse(); } @@ -516,12 +526,13 @@ extension VotingChainResponsePatterns on VotingChainResponse { @optionalTypeArgs TResult when( - TResult Function(int statusCode, String body) $default, + TResult Function(int statusCode, String body, BigInt? retryAfterSecs) + $default, ) { final _that = this; switch (_that) { case _VotingChainResponse(): - return $default(_that.statusCode, _that.body); + return $default(_that.statusCode, _that.body, _that.retryAfterSecs); } } @@ -539,12 +550,13 @@ extension VotingChainResponsePatterns on VotingChainResponse { @optionalTypeArgs TResult? whenOrNull( - TResult? Function(int statusCode, String body)? $default, + TResult? Function(int statusCode, String body, BigInt? retryAfterSecs)? + $default, ) { final _that = this; switch (_that) { case _VotingChainResponse() when $default != null: - return $default(_that.statusCode, _that.body); + return $default(_that.statusCode, _that.body, _that.retryAfterSecs); case _: return null; } @@ -554,12 +566,15 @@ extension VotingChainResponsePatterns on VotingChainResponse { /// @nodoc class _VotingChainResponse implements VotingChainResponse { - const _VotingChainResponse({required this.statusCode, required this.body}); + const _VotingChainResponse( + {required this.statusCode, required this.body, this.retryAfterSecs}); @override final int statusCode; @override final String body; + @override + final BigInt? retryAfterSecs; /// Create a copy of VotingChainResponse /// with the given fields replaced by the non-null parameter values. @@ -577,15 +592,18 @@ class _VotingChainResponse implements VotingChainResponse { other is _VotingChainResponse && (identical(other.statusCode, statusCode) || other.statusCode == statusCode) && - (identical(other.body, body) || other.body == body)); + (identical(other.body, body) || other.body == body) && + (identical(other.retryAfterSecs, retryAfterSecs) || + other.retryAfterSecs == retryAfterSecs)); } @override - int get hashCode => Object.hash(runtimeType, statusCode, body); + int get hashCode => + Object.hash(runtimeType, statusCode, body, retryAfterSecs); @override String toString() { - return 'VotingChainResponse(statusCode: $statusCode, body: $body)'; + return 'VotingChainResponse(statusCode: $statusCode, body: $body, retryAfterSecs: $retryAfterSecs)'; } } @@ -597,7 +615,7 @@ abstract mixin class _$VotingChainResponseCopyWith<$Res> __$VotingChainResponseCopyWithImpl; @override @useResult - $Res call({int statusCode, String body}); + $Res call({int statusCode, String body, BigInt? retryAfterSecs}); } /// @nodoc @@ -615,6 +633,7 @@ class __$VotingChainResponseCopyWithImpl<$Res> $Res call({ Object? statusCode = null, Object? body = null, + Object? retryAfterSecs = freezed, }) { return _then(_VotingChainResponse( statusCode: null == statusCode @@ -625,6 +644,10 @@ class __$VotingChainResponseCopyWithImpl<$Res> ? _self.body : body // ignore: cast_nullable_to_non_nullable as String, + retryAfterSecs: freezed == retryAfterSecs + ? _self.retryAfterSecs + : retryAfterSecs // ignore: cast_nullable_to_non_nullable + as BigInt?, )); } } @@ -8068,76 +8091,119 @@ class __$VotingRoundRecoveryCopyWithImpl<$Res> } /// @nodoc -mixin _$VotingServiceEndpoint { - String get url; - String get label; +mixin _$VotingRoundSession { + String get roundId; + VotingRoundPlan get plan; + VotingRoundRecovery get recovery; + List get intents; - /// Create a copy of VotingServiceEndpoint + /// Create a copy of VotingRoundSession /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') - $VotingServiceEndpointCopyWith get copyWith => - _$VotingServiceEndpointCopyWithImpl( - this as VotingServiceEndpoint, _$identity); + $VotingRoundSessionCopyWith get copyWith => + _$VotingRoundSessionCopyWithImpl( + this as VotingRoundSession, _$identity); @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is VotingServiceEndpoint && - (identical(other.url, url) || other.url == url) && - (identical(other.label, label) || other.label == label)); + other is VotingRoundSession && + (identical(other.roundId, roundId) || other.roundId == roundId) && + (identical(other.plan, plan) || other.plan == plan) && + (identical(other.recovery, recovery) || + other.recovery == recovery) && + const DeepCollectionEquality().equals(other.intents, intents)); } @override - int get hashCode => Object.hash(runtimeType, url, label); + int get hashCode => Object.hash(runtimeType, roundId, plan, recovery, + const DeepCollectionEquality().hash(intents)); @override String toString() { - return 'VotingServiceEndpoint(url: $url, label: $label)'; + return 'VotingRoundSession(roundId: $roundId, plan: $plan, recovery: $recovery, intents: $intents)'; } } /// @nodoc -abstract mixin class $VotingServiceEndpointCopyWith<$Res> { - factory $VotingServiceEndpointCopyWith(VotingServiceEndpoint value, - $Res Function(VotingServiceEndpoint) _then) = - _$VotingServiceEndpointCopyWithImpl; +abstract mixin class $VotingRoundSessionCopyWith<$Res> { + factory $VotingRoundSessionCopyWith( + VotingRoundSession value, $Res Function(VotingRoundSession) _then) = + _$VotingRoundSessionCopyWithImpl; @useResult - $Res call({String url, String label}); + $Res call( + {String roundId, + VotingRoundPlan plan, + VotingRoundRecovery recovery, + List intents}); + + $VotingRoundPlanCopyWith<$Res> get plan; + $VotingRoundRecoveryCopyWith<$Res> get recovery; } /// @nodoc -class _$VotingServiceEndpointCopyWithImpl<$Res> - implements $VotingServiceEndpointCopyWith<$Res> { - _$VotingServiceEndpointCopyWithImpl(this._self, this._then); +class _$VotingRoundSessionCopyWithImpl<$Res> + implements $VotingRoundSessionCopyWith<$Res> { + _$VotingRoundSessionCopyWithImpl(this._self, this._then); - final VotingServiceEndpoint _self; - final $Res Function(VotingServiceEndpoint) _then; + final VotingRoundSession _self; + final $Res Function(VotingRoundSession) _then; - /// Create a copy of VotingServiceEndpoint + /// Create a copy of VotingRoundSession /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ - Object? url = null, - Object? label = null, + Object? roundId = null, + Object? plan = null, + Object? recovery = null, + Object? intents = null, }) { return _then(_self.copyWith( - url: null == url - ? _self.url - : url // ignore: cast_nullable_to_non_nullable - as String, - label: null == label - ? _self.label - : label // ignore: cast_nullable_to_non_nullable + roundId: null == roundId + ? _self.roundId + : roundId // ignore: cast_nullable_to_non_nullable as String, + plan: null == plan + ? _self.plan + : plan // ignore: cast_nullable_to_non_nullable + as VotingRoundPlan, + recovery: null == recovery + ? _self.recovery + : recovery // ignore: cast_nullable_to_non_nullable + as VotingRoundRecovery, + intents: null == intents + ? _self.intents + : intents // ignore: cast_nullable_to_non_nullable + as List, )); } + + /// Create a copy of VotingRoundSession + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VotingRoundPlanCopyWith<$Res> get plan { + return $VotingRoundPlanCopyWith<$Res>(_self.plan, (value) { + return _then(_self.copyWith(plan: value)); + }); + } + + /// Create a copy of VotingRoundSession + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VotingRoundRecoveryCopyWith<$Res> get recovery { + return $VotingRoundRecoveryCopyWith<$Res>(_self.recovery, (value) { + return _then(_self.copyWith(recovery: value)); + }); + } } -/// Adds pattern-matching-related methods to [VotingServiceEndpoint]. -extension VotingServiceEndpointPatterns on VotingServiceEndpoint { +/// Adds pattern-matching-related methods to [VotingRoundSession]. +extension VotingRoundSessionPatterns on VotingRoundSession { /// A variant of `map` that fallback to returning `orElse`. /// /// It is equivalent to doing: @@ -8152,12 +8218,12 @@ extension VotingServiceEndpointPatterns on VotingServiceEndpoint { @optionalTypeArgs TResult maybeMap( - TResult Function(_VotingServiceEndpoint value)? $default, { + TResult Function(_VotingRoundSession value)? $default, { required TResult orElse(), }) { final _that = this; switch (_that) { - case _VotingServiceEndpoint() when $default != null: + case _VotingRoundSession() when $default != null: return $default(_that); case _: return orElse(); @@ -8179,11 +8245,11 @@ extension VotingServiceEndpointPatterns on VotingServiceEndpoint { @optionalTypeArgs TResult map( - TResult Function(_VotingServiceEndpoint value) $default, + TResult Function(_VotingRoundSession value) $default, ) { final _that = this; switch (_that) { - case _VotingServiceEndpoint(): + case _VotingRoundSession(): return $default(_that); } } @@ -8202,11 +8268,11 @@ extension VotingServiceEndpointPatterns on VotingServiceEndpoint { @optionalTypeArgs TResult? mapOrNull( - TResult? Function(_VotingServiceEndpoint value)? $default, + TResult? Function(_VotingRoundSession value)? $default, ) { final _that = this; switch (_that) { - case _VotingServiceEndpoint() when $default != null: + case _VotingRoundSession() when $default != null: return $default(_that); case _: return null; @@ -8227,13 +8293,16 @@ extension VotingServiceEndpointPatterns on VotingServiceEndpoint { @optionalTypeArgs TResult maybeWhen( - TResult Function(String url, String label)? $default, { + TResult Function(String roundId, VotingRoundPlan plan, + VotingRoundRecovery recovery, List intents)? + $default, { required TResult orElse(), }) { final _that = this; switch (_that) { - case _VotingServiceEndpoint() when $default != null: - return $default(_that.url, _that.label); + case _VotingRoundSession() when $default != null: + return $default( + _that.roundId, _that.plan, _that.recovery, _that.intents); case _: return orElse(); } @@ -8254,12 +8323,15 @@ extension VotingServiceEndpointPatterns on VotingServiceEndpoint { @optionalTypeArgs TResult when( - TResult Function(String url, String label) $default, + TResult Function(String roundId, VotingRoundPlan plan, + VotingRoundRecovery recovery, List intents) + $default, ) { final _that = this; switch (_that) { - case _VotingServiceEndpoint(): - return $default(_that.url, _that.label); + case _VotingRoundSession(): + return $default( + _that.roundId, _that.plan, _that.recovery, _that.intents); } } @@ -8277,12 +8349,15 @@ extension VotingServiceEndpointPatterns on VotingServiceEndpoint { @optionalTypeArgs TResult? whenOrNull( - TResult? Function(String url, String label)? $default, + TResult? Function(String roundId, VotingRoundPlan plan, + VotingRoundRecovery recovery, List intents)? + $default, ) { final _that = this; switch (_that) { - case _VotingServiceEndpoint() when $default != null: - return $default(_that.url, _that.label); + case _VotingRoundSession() when $default != null: + return $default( + _that.roundId, _that.plan, _that.recovery, _that.intents); case _: return null; } @@ -8291,195 +8366,550 @@ extension VotingServiceEndpointPatterns on VotingServiceEndpoint { /// @nodoc -class _VotingServiceEndpoint implements VotingServiceEndpoint { - const _VotingServiceEndpoint({required this.url, required this.label}); +class _VotingRoundSession implements VotingRoundSession { + const _VotingRoundSession( + {required this.roundId, + required this.plan, + required this.recovery, + required final List intents}) + : _intents = intents; @override - final String url; + final String roundId; @override - final String label; + final VotingRoundPlan plan; + @override + final VotingRoundRecovery recovery; + final List _intents; + @override + List get intents { + if (_intents is EqualUnmodifiableListView) return _intents; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_intents); + } - /// Create a copy of VotingServiceEndpoint + /// Create a copy of VotingRoundSession /// with the given fields replaced by the non-null parameter values. @override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') - _$VotingServiceEndpointCopyWith<_VotingServiceEndpoint> get copyWith => - __$VotingServiceEndpointCopyWithImpl<_VotingServiceEndpoint>( - this, _$identity); + _$VotingRoundSessionCopyWith<_VotingRoundSession> get copyWith => + __$VotingRoundSessionCopyWithImpl<_VotingRoundSession>(this, _$identity); @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _VotingServiceEndpoint && - (identical(other.url, url) || other.url == url) && - (identical(other.label, label) || other.label == label)); + other is _VotingRoundSession && + (identical(other.roundId, roundId) || other.roundId == roundId) && + (identical(other.plan, plan) || other.plan == plan) && + (identical(other.recovery, recovery) || + other.recovery == recovery) && + const DeepCollectionEquality().equals(other._intents, _intents)); } @override - int get hashCode => Object.hash(runtimeType, url, label); + int get hashCode => Object.hash(runtimeType, roundId, plan, recovery, + const DeepCollectionEquality().hash(_intents)); @override String toString() { - return 'VotingServiceEndpoint(url: $url, label: $label)'; + return 'VotingRoundSession(roundId: $roundId, plan: $plan, recovery: $recovery, intents: $intents)'; } } /// @nodoc -abstract mixin class _$VotingServiceEndpointCopyWith<$Res> - implements $VotingServiceEndpointCopyWith<$Res> { - factory _$VotingServiceEndpointCopyWith(_VotingServiceEndpoint value, - $Res Function(_VotingServiceEndpoint) _then) = - __$VotingServiceEndpointCopyWithImpl; +abstract mixin class _$VotingRoundSessionCopyWith<$Res> + implements $VotingRoundSessionCopyWith<$Res> { + factory _$VotingRoundSessionCopyWith( + _VotingRoundSession value, $Res Function(_VotingRoundSession) _then) = + __$VotingRoundSessionCopyWithImpl; @override @useResult - $Res call({String url, String label}); + $Res call( + {String roundId, + VotingRoundPlan plan, + VotingRoundRecovery recovery, + List intents}); + + @override + $VotingRoundPlanCopyWith<$Res> get plan; + @override + $VotingRoundRecoveryCopyWith<$Res> get recovery; } /// @nodoc -class __$VotingServiceEndpointCopyWithImpl<$Res> - implements _$VotingServiceEndpointCopyWith<$Res> { - __$VotingServiceEndpointCopyWithImpl(this._self, this._then); +class __$VotingRoundSessionCopyWithImpl<$Res> + implements _$VotingRoundSessionCopyWith<$Res> { + __$VotingRoundSessionCopyWithImpl(this._self, this._then); - final _VotingServiceEndpoint _self; - final $Res Function(_VotingServiceEndpoint) _then; + final _VotingRoundSession _self; + final $Res Function(_VotingRoundSession) _then; - /// Create a copy of VotingServiceEndpoint + /// Create a copy of VotingRoundSession /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $Res call({ - Object? url = null, - Object? label = null, + Object? roundId = null, + Object? plan = null, + Object? recovery = null, + Object? intents = null, }) { - return _then(_VotingServiceEndpoint( - url: null == url - ? _self.url - : url // ignore: cast_nullable_to_non_nullable - as String, - label: null == label - ? _self.label - : label // ignore: cast_nullable_to_non_nullable + return _then(_VotingRoundSession( + roundId: null == roundId + ? _self.roundId + : roundId // ignore: cast_nullable_to_non_nullable as String, + plan: null == plan + ? _self.plan + : plan // ignore: cast_nullable_to_non_nullable + as VotingRoundPlan, + recovery: null == recovery + ? _self.recovery + : recovery // ignore: cast_nullable_to_non_nullable + as VotingRoundRecovery, + intents: null == intents + ? _self._intents + : intents // ignore: cast_nullable_to_non_nullable + as List, )); } + + /// Create a copy of VotingRoundSession + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VotingRoundPlanCopyWith<$Res> get plan { + return $VotingRoundPlanCopyWith<$Res>(_self.plan, (value) { + return _then(_self.copyWith(plan: value)); + }); + } + + /// Create a copy of VotingRoundSession + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $VotingRoundRecoveryCopyWith<$Res> get recovery { + return $VotingRoundRecoveryCopyWith<$Res>(_self.recovery, (value) { + return _then(_self.copyWith(recovery: value)); + }); + } } /// @nodoc -mixin _$VotingShareDelegationRecord { - String get roundId; - int get bundleIndex; - int get proposalId; - int get shareIndex; - List get sentToUrls; - Uint8List get nullifier; - bool get confirmed; - BigInt get submitAt; - BigInt get createdAt; +mixin _$VotingServiceEndpoint { + String get url; + String get label; - /// Create a copy of VotingShareDelegationRecord + /// Create a copy of VotingServiceEndpoint /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') - $VotingShareDelegationRecordCopyWith - get copyWith => _$VotingShareDelegationRecordCopyWithImpl< - VotingShareDelegationRecord>( - this as VotingShareDelegationRecord, _$identity); + $VotingServiceEndpointCopyWith get copyWith => + _$VotingServiceEndpointCopyWithImpl( + this as VotingServiceEndpoint, _$identity); @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is VotingShareDelegationRecord && - (identical(other.roundId, roundId) || other.roundId == roundId) && - (identical(other.bundleIndex, bundleIndex) || - other.bundleIndex == bundleIndex) && - (identical(other.proposalId, proposalId) || - other.proposalId == proposalId) && - (identical(other.shareIndex, shareIndex) || - other.shareIndex == shareIndex) && - const DeepCollectionEquality() - .equals(other.sentToUrls, sentToUrls) && - const DeepCollectionEquality().equals(other.nullifier, nullifier) && - (identical(other.confirmed, confirmed) || - other.confirmed == confirmed) && - (identical(other.submitAt, submitAt) || - other.submitAt == submitAt) && - (identical(other.createdAt, createdAt) || - other.createdAt == createdAt)); + other is VotingServiceEndpoint && + (identical(other.url, url) || other.url == url) && + (identical(other.label, label) || other.label == label)); } @override - int get hashCode => Object.hash( - runtimeType, - roundId, - bundleIndex, - proposalId, - shareIndex, - const DeepCollectionEquality().hash(sentToUrls), - const DeepCollectionEquality().hash(nullifier), - confirmed, - submitAt, - createdAt); + int get hashCode => Object.hash(runtimeType, url, label); @override String toString() { - return 'VotingShareDelegationRecord(roundId: $roundId, bundleIndex: $bundleIndex, proposalId: $proposalId, shareIndex: $shareIndex, sentToUrls: $sentToUrls, nullifier: $nullifier, confirmed: $confirmed, submitAt: $submitAt, createdAt: $createdAt)'; + return 'VotingServiceEndpoint(url: $url, label: $label)'; } } /// @nodoc -abstract mixin class $VotingShareDelegationRecordCopyWith<$Res> { - factory $VotingShareDelegationRecordCopyWith( - VotingShareDelegationRecord value, - $Res Function(VotingShareDelegationRecord) _then) = - _$VotingShareDelegationRecordCopyWithImpl; +abstract mixin class $VotingServiceEndpointCopyWith<$Res> { + factory $VotingServiceEndpointCopyWith(VotingServiceEndpoint value, + $Res Function(VotingServiceEndpoint) _then) = + _$VotingServiceEndpointCopyWithImpl; @useResult - $Res call( - {String roundId, - int bundleIndex, - int proposalId, - int shareIndex, - List sentToUrls, - Uint8List nullifier, - bool confirmed, - BigInt submitAt, - BigInt createdAt}); + $Res call({String url, String label}); } /// @nodoc -class _$VotingShareDelegationRecordCopyWithImpl<$Res> - implements $VotingShareDelegationRecordCopyWith<$Res> { - _$VotingShareDelegationRecordCopyWithImpl(this._self, this._then); +class _$VotingServiceEndpointCopyWithImpl<$Res> + implements $VotingServiceEndpointCopyWith<$Res> { + _$VotingServiceEndpointCopyWithImpl(this._self, this._then); - final VotingShareDelegationRecord _self; - final $Res Function(VotingShareDelegationRecord) _then; + final VotingServiceEndpoint _self; + final $Res Function(VotingServiceEndpoint) _then; - /// Create a copy of VotingShareDelegationRecord + /// Create a copy of VotingServiceEndpoint /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ - Object? roundId = null, - Object? bundleIndex = null, - Object? proposalId = null, - Object? shareIndex = null, - Object? sentToUrls = null, - Object? nullifier = null, - Object? confirmed = null, - Object? submitAt = null, - Object? createdAt = null, + Object? url = null, + Object? label = null, }) { return _then(_self.copyWith( - roundId: null == roundId - ? _self.roundId - : roundId // ignore: cast_nullable_to_non_nullable + url: null == url + ? _self.url + : url // ignore: cast_nullable_to_non_nullable as String, - bundleIndex: null == bundleIndex - ? _self.bundleIndex - : bundleIndex // ignore: cast_nullable_to_non_nullable - as int, + label: null == label + ? _self.label + : label // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// Adds pattern-matching-related methods to [VotingServiceEndpoint]. +extension VotingServiceEndpointPatterns on VotingServiceEndpoint { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_VotingServiceEndpoint value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _VotingServiceEndpoint() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_VotingServiceEndpoint value) $default, + ) { + final _that = this; + switch (_that) { + case _VotingServiceEndpoint(): + return $default(_that); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_VotingServiceEndpoint value)? $default, + ) { + final _that = this; + switch (_that) { + case _VotingServiceEndpoint() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function(String url, String label)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _VotingServiceEndpoint() when $default != null: + return $default(_that.url, _that.label); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function(String url, String label) $default, + ) { + final _that = this; + switch (_that) { + case _VotingServiceEndpoint(): + return $default(_that.url, _that.label); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(String url, String label)? $default, + ) { + final _that = this; + switch (_that) { + case _VotingServiceEndpoint() when $default != null: + return $default(_that.url, _that.label); + case _: + return null; + } + } +} + +/// @nodoc + +class _VotingServiceEndpoint implements VotingServiceEndpoint { + const _VotingServiceEndpoint({required this.url, required this.label}); + + @override + final String url; + @override + final String label; + + /// Create a copy of VotingServiceEndpoint + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$VotingServiceEndpointCopyWith<_VotingServiceEndpoint> get copyWith => + __$VotingServiceEndpointCopyWithImpl<_VotingServiceEndpoint>( + this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _VotingServiceEndpoint && + (identical(other.url, url) || other.url == url) && + (identical(other.label, label) || other.label == label)); + } + + @override + int get hashCode => Object.hash(runtimeType, url, label); + + @override + String toString() { + return 'VotingServiceEndpoint(url: $url, label: $label)'; + } +} + +/// @nodoc +abstract mixin class _$VotingServiceEndpointCopyWith<$Res> + implements $VotingServiceEndpointCopyWith<$Res> { + factory _$VotingServiceEndpointCopyWith(_VotingServiceEndpoint value, + $Res Function(_VotingServiceEndpoint) _then) = + __$VotingServiceEndpointCopyWithImpl; + @override + @useResult + $Res call({String url, String label}); +} + +/// @nodoc +class __$VotingServiceEndpointCopyWithImpl<$Res> + implements _$VotingServiceEndpointCopyWith<$Res> { + __$VotingServiceEndpointCopyWithImpl(this._self, this._then); + + final _VotingServiceEndpoint _self; + final $Res Function(_VotingServiceEndpoint) _then; + + /// Create a copy of VotingServiceEndpoint + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? url = null, + Object? label = null, + }) { + return _then(_VotingServiceEndpoint( + url: null == url + ? _self.url + : url // ignore: cast_nullable_to_non_nullable + as String, + label: null == label + ? _self.label + : label // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +mixin _$VotingShareDelegationRecord { + String get roundId; + int get bundleIndex; + int get proposalId; + int get shareIndex; + List get sentToUrls; + Uint8List get nullifier; + bool get confirmed; + BigInt get submitAt; + BigInt get createdAt; + + /// Create a copy of VotingShareDelegationRecord + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $VotingShareDelegationRecordCopyWith + get copyWith => _$VotingShareDelegationRecordCopyWithImpl< + VotingShareDelegationRecord>( + this as VotingShareDelegationRecord, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is VotingShareDelegationRecord && + (identical(other.roundId, roundId) || other.roundId == roundId) && + (identical(other.bundleIndex, bundleIndex) || + other.bundleIndex == bundleIndex) && + (identical(other.proposalId, proposalId) || + other.proposalId == proposalId) && + (identical(other.shareIndex, shareIndex) || + other.shareIndex == shareIndex) && + const DeepCollectionEquality() + .equals(other.sentToUrls, sentToUrls) && + const DeepCollectionEquality().equals(other.nullifier, nullifier) && + (identical(other.confirmed, confirmed) || + other.confirmed == confirmed) && + (identical(other.submitAt, submitAt) || + other.submitAt == submitAt) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + roundId, + bundleIndex, + proposalId, + shareIndex, + const DeepCollectionEquality().hash(sentToUrls), + const DeepCollectionEquality().hash(nullifier), + confirmed, + submitAt, + createdAt); + + @override + String toString() { + return 'VotingShareDelegationRecord(roundId: $roundId, bundleIndex: $bundleIndex, proposalId: $proposalId, shareIndex: $shareIndex, sentToUrls: $sentToUrls, nullifier: $nullifier, confirmed: $confirmed, submitAt: $submitAt, createdAt: $createdAt)'; + } +} + +/// @nodoc +abstract mixin class $VotingShareDelegationRecordCopyWith<$Res> { + factory $VotingShareDelegationRecordCopyWith( + VotingShareDelegationRecord value, + $Res Function(VotingShareDelegationRecord) _then) = + _$VotingShareDelegationRecordCopyWithImpl; + @useResult + $Res call( + {String roundId, + int bundleIndex, + int proposalId, + int shareIndex, + List sentToUrls, + Uint8List nullifier, + bool confirmed, + BigInt submitAt, + BigInt createdAt}); +} + +/// @nodoc +class _$VotingShareDelegationRecordCopyWithImpl<$Res> + implements $VotingShareDelegationRecordCopyWith<$Res> { + _$VotingShareDelegationRecordCopyWithImpl(this._self, this._then); + + final VotingShareDelegationRecord _self; + final $Res Function(VotingShareDelegationRecord) _then; + + /// Create a copy of VotingShareDelegationRecord + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? roundId = null, + Object? bundleIndex = null, + Object? proposalId = null, + Object? shareIndex = null, + Object? sentToUrls = null, + Object? nullifier = null, + Object? confirmed = null, + Object? submitAt = null, + Object? createdAt = null, + }) { + return _then(_self.copyWith( + roundId: null == roundId + ? _self.roundId + : roundId // ignore: cast_nullable_to_non_nullable + as String, + bundleIndex: null == bundleIndex + ? _self.bundleIndex + : bundleIndex // ignore: cast_nullable_to_non_nullable + as int, proposalId: null == proposalId ? _self.proposalId : proposalId // ignore: cast_nullable_to_non_nullable @@ -9832,35 +10262,290 @@ class __$VotingSharePlanCopyWithImpl<$Res> } /// @nodoc -mixin _$VotingSharePlanItem { - BigInt get submitAt; - int get targetCount; - List get targetServers; +mixin _$VotingSharePlanItem { + BigInt get submitAt; + int get targetCount; + List get targetServers; + + /// Create a copy of VotingSharePlanItem + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $VotingSharePlanItemCopyWith get copyWith => + _$VotingSharePlanItemCopyWithImpl( + this as VotingSharePlanItem, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is VotingSharePlanItem && + (identical(other.submitAt, submitAt) || + other.submitAt == submitAt) && + (identical(other.targetCount, targetCount) || + other.targetCount == targetCount) && + const DeepCollectionEquality() + .equals(other.targetServers, targetServers)); + } + + @override + int get hashCode => Object.hash(runtimeType, submitAt, targetCount, + const DeepCollectionEquality().hash(targetServers)); + + @override + String toString() { + return 'VotingSharePlanItem(submitAt: $submitAt, targetCount: $targetCount, targetServers: $targetServers)'; + } +} + +/// @nodoc +abstract mixin class $VotingSharePlanItemCopyWith<$Res> { + factory $VotingSharePlanItemCopyWith( + VotingSharePlanItem value, $Res Function(VotingSharePlanItem) _then) = + _$VotingSharePlanItemCopyWithImpl; + @useResult + $Res call({BigInt submitAt, int targetCount, List targetServers}); +} + +/// @nodoc +class _$VotingSharePlanItemCopyWithImpl<$Res> + implements $VotingSharePlanItemCopyWith<$Res> { + _$VotingSharePlanItemCopyWithImpl(this._self, this._then); + + final VotingSharePlanItem _self; + final $Res Function(VotingSharePlanItem) _then; + + /// Create a copy of VotingSharePlanItem + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? submitAt = null, + Object? targetCount = null, + Object? targetServers = null, + }) { + return _then(_self.copyWith( + submitAt: null == submitAt + ? _self.submitAt + : submitAt // ignore: cast_nullable_to_non_nullable + as BigInt, + targetCount: null == targetCount + ? _self.targetCount + : targetCount // ignore: cast_nullable_to_non_nullable + as int, + targetServers: null == targetServers + ? _self.targetServers + : targetServers // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// Adds pattern-matching-related methods to [VotingSharePlanItem]. +extension VotingSharePlanItemPatterns on VotingSharePlanItem { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_VotingSharePlanItem value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _VotingSharePlanItem() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_VotingSharePlanItem value) $default, + ) { + final _that = this; + switch (_that) { + case _VotingSharePlanItem(): + return $default(_that); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_VotingSharePlanItem value)? $default, + ) { + final _that = this; + switch (_that) { + case _VotingSharePlanItem() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + BigInt submitAt, int targetCount, List targetServers)? + $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _VotingSharePlanItem() when $default != null: + return $default(_that.submitAt, _that.targetCount, _that.targetServers); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function( + BigInt submitAt, int targetCount, List targetServers) + $default, + ) { + final _that = this; + switch (_that) { + case _VotingSharePlanItem(): + return $default(_that.submitAt, _that.targetCount, _that.targetServers); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function( + BigInt submitAt, int targetCount, List targetServers)? + $default, + ) { + final _that = this; + switch (_that) { + case _VotingSharePlanItem() when $default != null: + return $default(_that.submitAt, _that.targetCount, _that.targetServers); + case _: + return null; + } + } +} + +/// @nodoc + +class _VotingSharePlanItem implements VotingSharePlanItem { + const _VotingSharePlanItem( + {required this.submitAt, + required this.targetCount, + required final List targetServers}) + : _targetServers = targetServers; + + @override + final BigInt submitAt; + @override + final int targetCount; + final List _targetServers; + @override + List get targetServers { + if (_targetServers is EqualUnmodifiableListView) return _targetServers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_targetServers); + } /// Create a copy of VotingSharePlanItem /// with the given fields replaced by the non-null parameter values. + @override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') - $VotingSharePlanItemCopyWith get copyWith => - _$VotingSharePlanItemCopyWithImpl( - this as VotingSharePlanItem, _$identity); + _$VotingSharePlanItemCopyWith<_VotingSharePlanItem> get copyWith => + __$VotingSharePlanItemCopyWithImpl<_VotingSharePlanItem>( + this, _$identity); @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is VotingSharePlanItem && + other is _VotingSharePlanItem && (identical(other.submitAt, submitAt) || other.submitAt == submitAt) && (identical(other.targetCount, targetCount) || other.targetCount == targetCount) && const DeepCollectionEquality() - .equals(other.targetServers, targetServers)); + .equals(other._targetServers, _targetServers)); } @override int get hashCode => Object.hash(runtimeType, submitAt, targetCount, - const DeepCollectionEquality().hash(targetServers)); + const DeepCollectionEquality().hash(_targetServers)); @override String toString() { @@ -9869,32 +10554,34 @@ mixin _$VotingSharePlanItem { } /// @nodoc -abstract mixin class $VotingSharePlanItemCopyWith<$Res> { - factory $VotingSharePlanItemCopyWith( - VotingSharePlanItem value, $Res Function(VotingSharePlanItem) _then) = - _$VotingSharePlanItemCopyWithImpl; +abstract mixin class _$VotingSharePlanItemCopyWith<$Res> + implements $VotingSharePlanItemCopyWith<$Res> { + factory _$VotingSharePlanItemCopyWith(_VotingSharePlanItem value, + $Res Function(_VotingSharePlanItem) _then) = + __$VotingSharePlanItemCopyWithImpl; + @override @useResult $Res call({BigInt submitAt, int targetCount, List targetServers}); } /// @nodoc -class _$VotingSharePlanItemCopyWithImpl<$Res> - implements $VotingSharePlanItemCopyWith<$Res> { - _$VotingSharePlanItemCopyWithImpl(this._self, this._then); +class __$VotingSharePlanItemCopyWithImpl<$Res> + implements _$VotingSharePlanItemCopyWith<$Res> { + __$VotingSharePlanItemCopyWithImpl(this._self, this._then); - final VotingSharePlanItem _self; - final $Res Function(VotingSharePlanItem) _then; + final _VotingSharePlanItem _self; + final $Res Function(_VotingSharePlanItem) _then; /// Create a copy of VotingSharePlanItem /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') @override + @pragma('vm:prefer-inline') $Res call({ Object? submitAt = null, Object? targetCount = null, Object? targetServers = null, }) { - return _then(_self.copyWith( + return _then(_VotingSharePlanItem( submitAt: null == submitAt ? _self.submitAt : submitAt // ignore: cast_nullable_to_non_nullable @@ -9904,15 +10591,109 @@ class _$VotingSharePlanItemCopyWithImpl<$Res> : targetCount // ignore: cast_nullable_to_non_nullable as int, targetServers: null == targetServers - ? _self.targetServers + ? _self._targetServers : targetServers // ignore: cast_nullable_to_non_nullable as List, )); } } -/// Adds pattern-matching-related methods to [VotingSharePlanItem]. -extension VotingSharePlanItemPatterns on VotingSharePlanItem { +/// @nodoc +mixin _$VotingShareSubmissionPayload { + int get bundleIndex; + int get proposalId; + int get shareIndex; + BigInt? get vcTreePosition; + + /// Create a copy of VotingShareSubmissionPayload + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $VotingShareSubmissionPayloadCopyWith + get copyWith => _$VotingShareSubmissionPayloadCopyWithImpl< + VotingShareSubmissionPayload>( + this as VotingShareSubmissionPayload, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is VotingShareSubmissionPayload && + (identical(other.bundleIndex, bundleIndex) || + other.bundleIndex == bundleIndex) && + (identical(other.proposalId, proposalId) || + other.proposalId == proposalId) && + (identical(other.shareIndex, shareIndex) || + other.shareIndex == shareIndex) && + (identical(other.vcTreePosition, vcTreePosition) || + other.vcTreePosition == vcTreePosition)); + } + + @override + int get hashCode => Object.hash( + runtimeType, bundleIndex, proposalId, shareIndex, vcTreePosition); + + @override + String toString() { + return 'VotingShareSubmissionPayload(bundleIndex: $bundleIndex, proposalId: $proposalId, shareIndex: $shareIndex, vcTreePosition: $vcTreePosition)'; + } +} + +/// @nodoc +abstract mixin class $VotingShareSubmissionPayloadCopyWith<$Res> { + factory $VotingShareSubmissionPayloadCopyWith( + VotingShareSubmissionPayload value, + $Res Function(VotingShareSubmissionPayload) _then) = + _$VotingShareSubmissionPayloadCopyWithImpl; + @useResult + $Res call( + {int bundleIndex, + int proposalId, + int shareIndex, + BigInt? vcTreePosition}); +} + +/// @nodoc +class _$VotingShareSubmissionPayloadCopyWithImpl<$Res> + implements $VotingShareSubmissionPayloadCopyWith<$Res> { + _$VotingShareSubmissionPayloadCopyWithImpl(this._self, this._then); + + final VotingShareSubmissionPayload _self; + final $Res Function(VotingShareSubmissionPayload) _then; + + /// Create a copy of VotingShareSubmissionPayload + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? bundleIndex = null, + Object? proposalId = null, + Object? shareIndex = null, + Object? vcTreePosition = freezed, + }) { + return _then(_self.copyWith( + bundleIndex: null == bundleIndex + ? _self.bundleIndex + : bundleIndex // ignore: cast_nullable_to_non_nullable + as int, + proposalId: null == proposalId + ? _self.proposalId + : proposalId // ignore: cast_nullable_to_non_nullable + as int, + shareIndex: null == shareIndex + ? _self.shareIndex + : shareIndex // ignore: cast_nullable_to_non_nullable + as int, + vcTreePosition: freezed == vcTreePosition + ? _self.vcTreePosition + : vcTreePosition // ignore: cast_nullable_to_non_nullable + as BigInt?, + )); + } +} + +/// Adds pattern-matching-related methods to [VotingShareSubmissionPayload]. +extension VotingShareSubmissionPayloadPatterns on VotingShareSubmissionPayload { /// A variant of `map` that fallback to returning `orElse`. /// /// It is equivalent to doing: @@ -9927,12 +10708,12 @@ extension VotingSharePlanItemPatterns on VotingSharePlanItem { @optionalTypeArgs TResult maybeMap( - TResult Function(_VotingSharePlanItem value)? $default, { + TResult Function(_VotingShareSubmissionPayload value)? $default, { required TResult orElse(), }) { final _that = this; switch (_that) { - case _VotingSharePlanItem() when $default != null: + case _VotingShareSubmissionPayload() when $default != null: return $default(_that); case _: return orElse(); @@ -9954,11 +10735,11 @@ extension VotingSharePlanItemPatterns on VotingSharePlanItem { @optionalTypeArgs TResult map( - TResult Function(_VotingSharePlanItem value) $default, + TResult Function(_VotingShareSubmissionPayload value) $default, ) { final _that = this; switch (_that) { - case _VotingSharePlanItem(): + case _VotingShareSubmissionPayload(): return $default(_that); } } @@ -9977,11 +10758,11 @@ extension VotingSharePlanItemPatterns on VotingSharePlanItem { @optionalTypeArgs TResult? mapOrNull( - TResult? Function(_VotingSharePlanItem value)? $default, + TResult? Function(_VotingShareSubmissionPayload value)? $default, ) { final _that = this; switch (_that) { - case _VotingSharePlanItem() when $default != null: + case _VotingShareSubmissionPayload() when $default != null: return $default(_that); case _: return null; @@ -10002,15 +10783,16 @@ extension VotingSharePlanItemPatterns on VotingSharePlanItem { @optionalTypeArgs TResult maybeWhen( - TResult Function( - BigInt submitAt, int targetCount, List targetServers)? + TResult Function(int bundleIndex, int proposalId, int shareIndex, + BigInt? vcTreePosition)? $default, { required TResult orElse(), }) { final _that = this; switch (_that) { - case _VotingSharePlanItem() when $default != null: - return $default(_that.submitAt, _that.targetCount, _that.targetServers); + case _VotingShareSubmissionPayload() when $default != null: + return $default(_that.bundleIndex, _that.proposalId, _that.shareIndex, + _that.vcTreePosition); case _: return orElse(); } @@ -10031,14 +10813,15 @@ extension VotingSharePlanItemPatterns on VotingSharePlanItem { @optionalTypeArgs TResult when( - TResult Function( - BigInt submitAt, int targetCount, List targetServers) + TResult Function(int bundleIndex, int proposalId, int shareIndex, + BigInt? vcTreePosition) $default, ) { final _that = this; switch (_that) { - case _VotingSharePlanItem(): - return $default(_that.submitAt, _that.targetCount, _that.targetServers); + case _VotingShareSubmissionPayload(): + return $default(_that.bundleIndex, _that.proposalId, _that.shareIndex, + _that.vcTreePosition); } } @@ -10056,14 +10839,15 @@ extension VotingSharePlanItemPatterns on VotingSharePlanItem { @optionalTypeArgs TResult? whenOrNull( - TResult? Function( - BigInt submitAt, int targetCount, List targetServers)? + TResult? Function(int bundleIndex, int proposalId, int shareIndex, + BigInt? vcTreePosition)? $default, ) { final _that = this; switch (_that) { - case _VotingSharePlanItem() when $default != null: - return $default(_that.submitAt, _that.targetCount, _that.targetServers); + case _VotingShareSubmissionPayload() when $default != null: + return $default(_that.bundleIndex, _that.proposalId, _that.shareIndex, + _that.vcTreePosition); case _: return null; } @@ -10072,98 +10856,107 @@ extension VotingSharePlanItemPatterns on VotingSharePlanItem { /// @nodoc -class _VotingSharePlanItem implements VotingSharePlanItem { - const _VotingSharePlanItem( - {required this.submitAt, - required this.targetCount, - required final List targetServers}) - : _targetServers = targetServers; +class _VotingShareSubmissionPayload implements VotingShareSubmissionPayload { + const _VotingShareSubmissionPayload( + {required this.bundleIndex, + required this.proposalId, + required this.shareIndex, + this.vcTreePosition}); @override - final BigInt submitAt; + final int bundleIndex; @override - final int targetCount; - final List _targetServers; + final int proposalId; @override - List get targetServers { - if (_targetServers is EqualUnmodifiableListView) return _targetServers; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_targetServers); - } + final int shareIndex; + @override + final BigInt? vcTreePosition; - /// Create a copy of VotingSharePlanItem + /// Create a copy of VotingShareSubmissionPayload /// with the given fields replaced by the non-null parameter values. @override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') - _$VotingSharePlanItemCopyWith<_VotingSharePlanItem> get copyWith => - __$VotingSharePlanItemCopyWithImpl<_VotingSharePlanItem>( - this, _$identity); + _$VotingShareSubmissionPayloadCopyWith<_VotingShareSubmissionPayload> + get copyWith => __$VotingShareSubmissionPayloadCopyWithImpl< + _VotingShareSubmissionPayload>(this, _$identity); @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _VotingSharePlanItem && - (identical(other.submitAt, submitAt) || - other.submitAt == submitAt) && - (identical(other.targetCount, targetCount) || - other.targetCount == targetCount) && - const DeepCollectionEquality() - .equals(other._targetServers, _targetServers)); + other is _VotingShareSubmissionPayload && + (identical(other.bundleIndex, bundleIndex) || + other.bundleIndex == bundleIndex) && + (identical(other.proposalId, proposalId) || + other.proposalId == proposalId) && + (identical(other.shareIndex, shareIndex) || + other.shareIndex == shareIndex) && + (identical(other.vcTreePosition, vcTreePosition) || + other.vcTreePosition == vcTreePosition)); } @override - int get hashCode => Object.hash(runtimeType, submitAt, targetCount, - const DeepCollectionEquality().hash(_targetServers)); + int get hashCode => Object.hash( + runtimeType, bundleIndex, proposalId, shareIndex, vcTreePosition); @override String toString() { - return 'VotingSharePlanItem(submitAt: $submitAt, targetCount: $targetCount, targetServers: $targetServers)'; + return 'VotingShareSubmissionPayload(bundleIndex: $bundleIndex, proposalId: $proposalId, shareIndex: $shareIndex, vcTreePosition: $vcTreePosition)'; } } /// @nodoc -abstract mixin class _$VotingSharePlanItemCopyWith<$Res> - implements $VotingSharePlanItemCopyWith<$Res> { - factory _$VotingSharePlanItemCopyWith(_VotingSharePlanItem value, - $Res Function(_VotingSharePlanItem) _then) = - __$VotingSharePlanItemCopyWithImpl; +abstract mixin class _$VotingShareSubmissionPayloadCopyWith<$Res> + implements $VotingShareSubmissionPayloadCopyWith<$Res> { + factory _$VotingShareSubmissionPayloadCopyWith( + _VotingShareSubmissionPayload value, + $Res Function(_VotingShareSubmissionPayload) _then) = + __$VotingShareSubmissionPayloadCopyWithImpl; @override @useResult - $Res call({BigInt submitAt, int targetCount, List targetServers}); + $Res call( + {int bundleIndex, + int proposalId, + int shareIndex, + BigInt? vcTreePosition}); } /// @nodoc -class __$VotingSharePlanItemCopyWithImpl<$Res> - implements _$VotingSharePlanItemCopyWith<$Res> { - __$VotingSharePlanItemCopyWithImpl(this._self, this._then); +class __$VotingShareSubmissionPayloadCopyWithImpl<$Res> + implements _$VotingShareSubmissionPayloadCopyWith<$Res> { + __$VotingShareSubmissionPayloadCopyWithImpl(this._self, this._then); - final _VotingSharePlanItem _self; - final $Res Function(_VotingSharePlanItem) _then; + final _VotingShareSubmissionPayload _self; + final $Res Function(_VotingShareSubmissionPayload) _then; - /// Create a copy of VotingSharePlanItem + /// Create a copy of VotingShareSubmissionPayload /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $Res call({ - Object? submitAt = null, - Object? targetCount = null, - Object? targetServers = null, + Object? bundleIndex = null, + Object? proposalId = null, + Object? shareIndex = null, + Object? vcTreePosition = freezed, }) { - return _then(_VotingSharePlanItem( - submitAt: null == submitAt - ? _self.submitAt - : submitAt // ignore: cast_nullable_to_non_nullable - as BigInt, - targetCount: null == targetCount - ? _self.targetCount - : targetCount // ignore: cast_nullable_to_non_nullable + return _then(_VotingShareSubmissionPayload( + bundleIndex: null == bundleIndex + ? _self.bundleIndex + : bundleIndex // ignore: cast_nullable_to_non_nullable + as int, + proposalId: null == proposalId + ? _self.proposalId + : proposalId // ignore: cast_nullable_to_non_nullable + as int, + shareIndex: null == shareIndex + ? _self.shareIndex + : shareIndex // ignore: cast_nullable_to_non_nullable as int, - targetServers: null == targetServers - ? _self._targetServers - : targetServers // ignore: cast_nullable_to_non_nullable - as List, + vcTreePosition: freezed == vcTreePosition + ? _self.vcTreePosition + : vcTreePosition // ignore: cast_nullable_to_non_nullable + as BigInt?, )); } } @@ -11470,6 +12263,312 @@ class __$VotingSignedVoteCommitmentCopyWithImpl<$Res> } } +/// @nodoc +mixin _$VotingTreeVoteConfirmation { + BigInt get vcTreePosition; + int? get vanLeafPosition; + + /// Create a copy of VotingTreeVoteConfirmation + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $VotingTreeVoteConfirmationCopyWith + get copyWith => + _$VotingTreeVoteConfirmationCopyWithImpl( + this as VotingTreeVoteConfirmation, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is VotingTreeVoteConfirmation && + (identical(other.vcTreePosition, vcTreePosition) || + other.vcTreePosition == vcTreePosition) && + (identical(other.vanLeafPosition, vanLeafPosition) || + other.vanLeafPosition == vanLeafPosition)); + } + + @override + int get hashCode => Object.hash(runtimeType, vcTreePosition, vanLeafPosition); + + @override + String toString() { + return 'VotingTreeVoteConfirmation(vcTreePosition: $vcTreePosition, vanLeafPosition: $vanLeafPosition)'; + } +} + +/// @nodoc +abstract mixin class $VotingTreeVoteConfirmationCopyWith<$Res> { + factory $VotingTreeVoteConfirmationCopyWith(VotingTreeVoteConfirmation value, + $Res Function(VotingTreeVoteConfirmation) _then) = + _$VotingTreeVoteConfirmationCopyWithImpl; + @useResult + $Res call({BigInt vcTreePosition, int? vanLeafPosition}); +} + +/// @nodoc +class _$VotingTreeVoteConfirmationCopyWithImpl<$Res> + implements $VotingTreeVoteConfirmationCopyWith<$Res> { + _$VotingTreeVoteConfirmationCopyWithImpl(this._self, this._then); + + final VotingTreeVoteConfirmation _self; + final $Res Function(VotingTreeVoteConfirmation) _then; + + /// Create a copy of VotingTreeVoteConfirmation + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? vcTreePosition = null, + Object? vanLeafPosition = freezed, + }) { + return _then(_self.copyWith( + vcTreePosition: null == vcTreePosition + ? _self.vcTreePosition + : vcTreePosition // ignore: cast_nullable_to_non_nullable + as BigInt, + vanLeafPosition: freezed == vanLeafPosition + ? _self.vanLeafPosition + : vanLeafPosition // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// Adds pattern-matching-related methods to [VotingTreeVoteConfirmation]. +extension VotingTreeVoteConfirmationPatterns on VotingTreeVoteConfirmation { + /// A variant of `map` that fallback to returning `orElse`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeMap( + TResult Function(_VotingTreeVoteConfirmation value)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _VotingTreeVoteConfirmation() when $default != null: + return $default(_that); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// Callbacks receives the raw object, upcasted. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case final Subclass2 value: + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult map( + TResult Function(_VotingTreeVoteConfirmation value) $default, + ) { + final _that = this; + switch (_that) { + case _VotingTreeVoteConfirmation(): + return $default(_that); + } + } + + /// A variant of `map` that fallback to returning `null`. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case final Subclass value: + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_VotingTreeVoteConfirmation value)? $default, + ) { + final _that = this; + switch (_that) { + case _VotingTreeVoteConfirmation() when $default != null: + return $default(_that); + case _: + return null; + } + } + + /// A variant of `when` that fallback to an `orElse` callback. + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return orElse(); + /// } + /// ``` + + @optionalTypeArgs + TResult maybeWhen( + TResult Function(BigInt vcTreePosition, int? vanLeafPosition)? $default, { + required TResult orElse(), + }) { + final _that = this; + switch (_that) { + case _VotingTreeVoteConfirmation() when $default != null: + return $default(_that.vcTreePosition, _that.vanLeafPosition); + case _: + return orElse(); + } + } + + /// A `switch`-like method, using callbacks. + /// + /// As opposed to `map`, this offers destructuring. + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case Subclass2(:final field2): + /// return ...; + /// } + /// ``` + + @optionalTypeArgs + TResult when( + TResult Function(BigInt vcTreePosition, int? vanLeafPosition) $default, + ) { + final _that = this; + switch (_that) { + case _VotingTreeVoteConfirmation(): + return $default(_that.vcTreePosition, _that.vanLeafPosition); + } + } + + /// A variant of `when` that fallback to returning `null` + /// + /// It is equivalent to doing: + /// ```dart + /// switch (sealedClass) { + /// case Subclass(:final field): + /// return ...; + /// case _: + /// return null; + /// } + /// ``` + + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(BigInt vcTreePosition, int? vanLeafPosition)? $default, + ) { + final _that = this; + switch (_that) { + case _VotingTreeVoteConfirmation() when $default != null: + return $default(_that.vcTreePosition, _that.vanLeafPosition); + case _: + return null; + } + } +} + +/// @nodoc + +class _VotingTreeVoteConfirmation implements VotingTreeVoteConfirmation { + const _VotingTreeVoteConfirmation( + {required this.vcTreePosition, this.vanLeafPosition}); + + @override + final BigInt vcTreePosition; + @override + final int? vanLeafPosition; + + /// Create a copy of VotingTreeVoteConfirmation + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$VotingTreeVoteConfirmationCopyWith<_VotingTreeVoteConfirmation> + get copyWith => __$VotingTreeVoteConfirmationCopyWithImpl< + _VotingTreeVoteConfirmation>(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _VotingTreeVoteConfirmation && + (identical(other.vcTreePosition, vcTreePosition) || + other.vcTreePosition == vcTreePosition) && + (identical(other.vanLeafPosition, vanLeafPosition) || + other.vanLeafPosition == vanLeafPosition)); + } + + @override + int get hashCode => Object.hash(runtimeType, vcTreePosition, vanLeafPosition); + + @override + String toString() { + return 'VotingTreeVoteConfirmation(vcTreePosition: $vcTreePosition, vanLeafPosition: $vanLeafPosition)'; + } +} + +/// @nodoc +abstract mixin class _$VotingTreeVoteConfirmationCopyWith<$Res> + implements $VotingTreeVoteConfirmationCopyWith<$Res> { + factory _$VotingTreeVoteConfirmationCopyWith( + _VotingTreeVoteConfirmation value, + $Res Function(_VotingTreeVoteConfirmation) _then) = + __$VotingTreeVoteConfirmationCopyWithImpl; + @override + @useResult + $Res call({BigInt vcTreePosition, int? vanLeafPosition}); +} + +/// @nodoc +class __$VotingTreeVoteConfirmationCopyWithImpl<$Res> + implements _$VotingTreeVoteConfirmationCopyWith<$Res> { + __$VotingTreeVoteConfirmationCopyWithImpl(this._self, this._then); + + final _VotingTreeVoteConfirmation _self; + final $Res Function(_VotingTreeVoteConfirmation) _then; + + /// Create a copy of VotingTreeVoteConfirmation + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $Res call({ + Object? vcTreePosition = null, + Object? vanLeafPosition = freezed, + }) { + return _then(_VotingTreeVoteConfirmation( + vcTreePosition: null == vcTreePosition + ? _self.vcTreePosition + : vcTreePosition // ignore: cast_nullable_to_non_nullable + as BigInt, + vanLeafPosition: freezed == vanLeafPosition + ? _self.vanLeafPosition + : vanLeafPosition // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + /// @nodoc mixin _$VotingVanWitness { List get authPath; diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index d646a82c1..75e00888e 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -95,7 +95,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 1766202353; + int get rustContentHash => -353494689; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -740,12 +740,18 @@ abstract class RustLibApi extends BaseApi { required String eventsJson, required Coin c}); + Future crateApiVotingVotingDelegationVanCommitmentHex( + {required String roundId, required int bundleIndex, required Coin c}); + Future crateApiVotingVotingDraftsLoad( {required String roundId, required Coin c}); Future crateApiVotingVotingDraftsSave( {required String roundId, required String draftsJson, required Coin c}); + Future crateApiVotingVotingEligibleWeight( + {required int snapshotHeight, required Coin c}); + Future crateApiVotingVotingHotkeyCreate({required Coin c}); Future crateApiVotingVotingHotkeyGet({required Coin c}); @@ -777,12 +783,30 @@ abstract class RustLibApi extends BaseApi { required String shareDeliveriesJson, required Coin c}); + Future crateApiVotingVotingRecoverConfirmDelegationFromTree( + {required String roundId, + required int bundleIndex, + required int vanLeafPosition, + required Coin c}); + + Future + crateApiVotingVotingRecoverConfirmVoteFromTree( + {required String roundId, + required int bundleIndex, + required int proposalId, + required BigInt vcTreePosition, + int? vanLeafPosition, + required Coin c}); + Future crateApiVotingVotingRecovery( {required String roundId, required Coin c}); Future crateApiVotingVotingRecoveryClear( {required String roundId, required Coin c}); + Future crateApiVotingVotingResetSessionState( + {required String roundId, required Coin c}); + Future crateApiVotingVotingRoundParamsJson( {required String source, required String roundId, @@ -793,6 +817,9 @@ abstract class RustLibApi extends BaseApi { Future> crateApiVotingVotingRounds({required Coin c}); + Future> crateApiVotingVotingSessions( + {required List roundIds, required Coin c}); + Future crateApiVotingVotingSetBallotIntent( {required String roundId, required int proposalId, @@ -816,6 +843,9 @@ abstract class RustLibApi extends BaseApi { required int shareIndex, required Coin c}); + Future> crateApiVotingVotingSharePayloads( + {required String roundId, required Coin c}); + Future crateApiVotingVotingSharePlan( {required String roundId, required BigInt now, @@ -825,6 +855,15 @@ abstract class RustLibApi extends BaseApi { required bool singleShare, required Coin c}); + Future> crateApiVotingVotingSharePlans( + {required int shareCount, + required List serverUrls, + required BigInt now, + required BigInt voteEnd, + required BigInt ceremonyStart, + required bool singleShare, + required Coin c}); + Future crateApiVotingVotingShareRecord( {required String roundId, required int bundleIndex, @@ -850,12 +889,29 @@ abstract class RustLibApi extends BaseApi { Future crateApiVotingVotingSyncTree( {required String roundId, required String voteNodeUrl, required Coin c}); + Future crateApiVotingVotingTreeFindLeaf( + {required String roundId, + required String nodeUrl, + required String targetHex}); + Future crateApiVotingVotingVanWitness( {required String roundId, required int bundleIndex, required String voteNodeUrl, required Coin c}); + Future crateApiVotingVotingVoteCommitmentHex( + {required String roundId, + required int bundleIndex, + required int proposalId, + required Coin c}); + + Future crateApiVotingVotingVoteVanCommitmentHex( + {required String roundId, + required int bundleIndex, + required int proposalId, + required Coin c}); + Future crateApiVotingVotingVoteWireJson( {required String roundId, required int bundleIndex, @@ -6885,6 +6941,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ], ); + @override + Future crateApiVotingVotingDelegationVanCommitmentHex( + {required String roundId, required int bundleIndex, required Coin c}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(roundId, serializer); + sse_encode_u_32(bundleIndex, serializer); + sse_encode_box_autoadd_coin(c, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 202, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiVotingVotingDelegationVanCommitmentHexConstMeta, + argValues: [roundId, bundleIndex, c], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVotingVotingDelegationVanCommitmentHexConstMeta => + const TaskConstMeta( + debugName: "voting_delegation_van_commitment_hex", + argNames: ["roundId", "bundleIndex", "c"], + ); + @override Future crateApiVotingVotingDraftsLoad( {required String roundId, required Coin c}) { @@ -6895,7 +6981,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(roundId, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 202, port: port_); + funcId: 203, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_opt_String, @@ -6925,7 +7011,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(draftsJson, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 203, port: port_); + funcId: 204, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -6944,6 +7030,35 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["roundId", "draftsJson", "c"], ); + @override + Future crateApiVotingVotingEligibleWeight( + {required int snapshotHeight, required Coin c}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(snapshotHeight, serializer); + sse_encode_box_autoadd_coin(c, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 205, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_u_64, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiVotingVotingEligibleWeightConstMeta, + argValues: [snapshotHeight, c], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVotingVotingEligibleWeightConstMeta => + const TaskConstMeta( + debugName: "voting_eligible_weight", + argNames: ["snapshotHeight", "c"], + ); + @override Future crateApiVotingVotingHotkeyCreate({required Coin c}) { return handler.executeNormal( @@ -6952,7 +7067,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 204, port: port_); + funcId: 206, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -6979,7 +7094,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 205, port: port_); + funcId: 207, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -7015,7 +7130,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(txHash, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 206, port: port_); + funcId: 208, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -7049,7 +7164,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_32(proposalId, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 207, port: port_); + funcId: 209, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_voting_vote_payloads, @@ -7081,7 +7196,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_32_loose(proposalIds, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 208, port: port_); + funcId: 210, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_voting_round_plan, @@ -7120,7 +7235,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(shareDeliveriesJson, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 209, port: port_); + funcId: 211, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -7155,6 +7270,95 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ], ); + @override + Future crateApiVotingVotingRecoverConfirmDelegationFromTree( + {required String roundId, + required int bundleIndex, + required int vanLeafPosition, + required Coin c}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(roundId, serializer); + sse_encode_u_32(bundleIndex, serializer); + sse_encode_u_32(vanLeafPosition, serializer); + sse_encode_box_autoadd_coin(c, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 212, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateApiVotingVotingRecoverConfirmDelegationFromTreeConstMeta, + argValues: [roundId, bundleIndex, vanLeafPosition, c], + apiImpl: this, + ), + ); + } + + TaskConstMeta + get kCrateApiVotingVotingRecoverConfirmDelegationFromTreeConstMeta => + const TaskConstMeta( + debugName: "voting_recover_confirm_delegation_from_tree", + argNames: ["roundId", "bundleIndex", "vanLeafPosition", "c"], + ); + + @override + Future + crateApiVotingVotingRecoverConfirmVoteFromTree( + {required String roundId, + required int bundleIndex, + required int proposalId, + required BigInt vcTreePosition, + int? vanLeafPosition, + required Coin c}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(roundId, serializer); + sse_encode_u_32(bundleIndex, serializer); + sse_encode_u_32(proposalId, serializer); + sse_encode_u_64(vcTreePosition, serializer); + sse_encode_opt_box_autoadd_u_32(vanLeafPosition, serializer); + sse_encode_box_autoadd_coin(c, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 213, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_voting_tree_vote_confirmation, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiVotingVotingRecoverConfirmVoteFromTreeConstMeta, + argValues: [ + roundId, + bundleIndex, + proposalId, + vcTreePosition, + vanLeafPosition, + c + ], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVotingVotingRecoverConfirmVoteFromTreeConstMeta => + const TaskConstMeta( + debugName: "voting_recover_confirm_vote_from_tree", + argNames: [ + "roundId", + "bundleIndex", + "proposalId", + "vcTreePosition", + "vanLeafPosition", + "c" + ], + ); + @override Future crateApiVotingVotingRecovery( {required String roundId, required Coin c}) { @@ -7165,7 +7369,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(roundId, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 210, port: port_); + funcId: 214, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_voting_round_recovery, @@ -7194,7 +7398,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(roundId, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 211, port: port_); + funcId: 215, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -7213,6 +7417,35 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["roundId", "c"], ); + @override + Future crateApiVotingVotingResetSessionState( + {required String roundId, required Coin c}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(roundId, serializer); + sse_encode_box_autoadd_coin(c, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 216, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiVotingVotingResetSessionStateConstMeta, + argValues: [roundId, c], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVotingVotingResetSessionStateConstMeta => + const TaskConstMeta( + debugName: "voting_reset_session_state", + argNames: ["roundId", "c"], + ); + @override Future crateApiVotingVotingRoundParamsJson( {required String source, @@ -7232,7 +7465,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_loose(nullifierImtRoot, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 212, port: port_); + funcId: 217, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -7273,7 +7506,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 213, port: port_); + funcId: 218, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_list_voting_round_info, @@ -7291,6 +7524,35 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["c"], ); + @override + Future> crateApiVotingVotingSessions( + {required List roundIds, required Coin c}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_String(roundIds, serializer); + sse_encode_box_autoadd_coin(c, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 219, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_voting_round_session, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiVotingVotingSessionsConstMeta, + argValues: [roundIds, c], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVotingVotingSessionsConstMeta => + const TaskConstMeta( + debugName: "voting_sessions", + argNames: ["roundIds", "c"], + ); + @override Future crateApiVotingVotingSetBallotIntent( {required String roundId, @@ -7310,7 +7572,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_32(numOptions, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 214, port: port_); + funcId: 220, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -7355,7 +7617,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_String(newUrls, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 215, port: port_); + funcId: 221, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -7398,7 +7660,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_32(shareIndex, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 216, port: port_); + funcId: 222, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -7417,6 +7679,35 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["roundId", "bundleIndex", "proposalId", "shareIndex", "c"], ); + @override + Future> crateApiVotingVotingSharePayloads( + {required String roundId, required Coin c}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(roundId, serializer); + sse_encode_box_autoadd_coin(c, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 223, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_voting_share_submission_payload, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiVotingVotingSharePayloadsConstMeta, + argValues: [roundId, c], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVotingVotingSharePayloadsConstMeta => + const TaskConstMeta( + debugName: "voting_share_payloads", + argNames: ["roundId", "c"], + ); + @override Future crateApiVotingVotingSharePlan( {required String roundId, @@ -7438,7 +7729,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bool(singleShare, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 217, port: port_); + funcId: 224, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_voting_share_plan, @@ -7473,6 +7764,62 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ], ); + @override + Future> crateApiVotingVotingSharePlans( + {required int shareCount, + required List serverUrls, + required BigInt now, + required BigInt voteEnd, + required BigInt ceremonyStart, + required bool singleShare, + required Coin c}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_u_32(shareCount, serializer); + sse_encode_list_String(serverUrls, serializer); + sse_encode_u_64(now, serializer); + sse_encode_u_64(voteEnd, serializer); + sse_encode_u_64(ceremonyStart, serializer); + sse_encode_bool(singleShare, serializer); + sse_encode_box_autoadd_coin(c, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 225, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_voting_share_plan_item, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiVotingVotingSharePlansConstMeta, + argValues: [ + shareCount, + serverUrls, + now, + voteEnd, + ceremonyStart, + singleShare, + c + ], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVotingVotingSharePlansConstMeta => + const TaskConstMeta( + debugName: "voting_share_plans", + argNames: [ + "shareCount", + "serverUrls", + "now", + "voteEnd", + "ceremonyStart", + "singleShare", + "c" + ], + ); + @override Future crateApiVotingVotingShareRecord( {required String roundId, @@ -7494,7 +7841,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_64(submitAt, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 218, port: port_); + funcId: 226, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -7540,7 +7887,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(roundId, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 219, port: port_); + funcId: 227, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_list_voting_share_delegation_record, @@ -7580,7 +7927,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_64(submitAt, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 220, port: port_); + funcId: 228, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -7626,7 +7973,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(voteNodeUrl, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 221, port: port_); + funcId: 229, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_u_32, @@ -7645,6 +7992,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["roundId", "voteNodeUrl", "c"], ); + @override + Future crateApiVotingVotingTreeFindLeaf( + {required String roundId, + required String nodeUrl, + required String targetHex}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(roundId, serializer); + sse_encode_String(nodeUrl, serializer); + sse_encode_String(targetHex, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 230, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_box_autoadd_u_64, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiVotingVotingTreeFindLeafConstMeta, + argValues: [roundId, nodeUrl, targetHex], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVotingVotingTreeFindLeafConstMeta => + const TaskConstMeta( + debugName: "voting_tree_find_leaf", + argNames: ["roundId", "nodeUrl", "targetHex"], + ); + @override Future crateApiVotingVotingVanWitness( {required String roundId, @@ -7660,7 +8039,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(voteNodeUrl, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 222, port: port_); + funcId: 231, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_voting_van_witness, @@ -7679,6 +8058,74 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["roundId", "bundleIndex", "voteNodeUrl", "c"], ); + @override + Future crateApiVotingVotingVoteCommitmentHex( + {required String roundId, + required int bundleIndex, + required int proposalId, + required Coin c}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(roundId, serializer); + sse_encode_u_32(bundleIndex, serializer); + sse_encode_u_32(proposalId, serializer); + sse_encode_box_autoadd_coin(c, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 232, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiVotingVotingVoteCommitmentHexConstMeta, + argValues: [roundId, bundleIndex, proposalId, c], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVotingVotingVoteCommitmentHexConstMeta => + const TaskConstMeta( + debugName: "voting_vote_commitment_hex", + argNames: ["roundId", "bundleIndex", "proposalId", "c"], + ); + + @override + Future crateApiVotingVotingVoteVanCommitmentHex( + {required String roundId, + required int bundleIndex, + required int proposalId, + required Coin c}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(roundId, serializer); + sse_encode_u_32(bundleIndex, serializer); + sse_encode_u_32(proposalId, serializer); + sse_encode_box_autoadd_coin(c, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 233, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiVotingVotingVoteVanCommitmentHexConstMeta, + argValues: [roundId, bundleIndex, proposalId, c], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiVotingVotingVoteVanCommitmentHexConstMeta => + const TaskConstMeta( + debugName: "voting_vote_van_commitment_hex", + argNames: ["roundId", "bundleIndex", "proposalId", "c"], + ); + @override Future crateApiVotingVotingVoteWireJson( {required String roundId, @@ -7694,7 +8141,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_32(proposalId, serializer); sse_encode_box_autoadd_coin(c, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 223, port: port_); + funcId: 234, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -8613,6 +9060,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (raw as List).map(dco_decode_voting_round_info).toList(); } + @protected + List dco_decode_list_voting_round_session(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_voting_round_session).toList(); + } + @protected List dco_decode_list_voting_service_endpoint( dynamic raw) { @@ -8646,6 +9099,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { .toList(); } + @protected + List + dco_decode_list_voting_share_submission_payload(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List) + .map(dco_decode_voting_share_submission_payload) + .toList(); + } + @protected List dco_decode_list_voting_share_workflow(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -9496,11 +9958,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { VotingChainResponse dco_decode_voting_chain_response(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 2) - throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + if (arr.length != 3) + throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); return VotingChainResponse( statusCode: dco_decode_u_16(arr[0]), body: dco_decode_String(arr[1]), + retryAfterSecs: dco_decode_opt_box_autoadd_u_64(arr[2]), ); } @@ -9795,6 +10258,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + VotingRoundSession dco_decode_voting_round_session(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 4) + throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + return VotingRoundSession( + roundId: dco_decode_String(arr[0]), + plan: dco_decode_voting_round_plan(arr[1]), + recovery: dco_decode_voting_round_recovery(arr[2]), + intents: dco_decode_list_voting_ballot_intent(arr[3]), + ); + } + @protected VotingServiceEndpoint dco_decode_voting_service_endpoint(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -9872,6 +10349,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + VotingShareSubmissionPayload dco_decode_voting_share_submission_payload( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 4) + throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + return VotingShareSubmissionPayload( + bundleIndex: dco_decode_u_32(arr[0]), + proposalId: dco_decode_u_32(arr[1]), + shareIndex: dco_decode_u_32(arr[2]), + vcTreePosition: dco_decode_opt_box_autoadd_u_64(arr[3]), + ); + } + @protected VotingShareTrackingSummary dco_decode_voting_share_tracking_summary( dynamic raw) { @@ -9924,6 +10416,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + VotingTreeVoteConfirmation dco_decode_voting_tree_vote_confirmation( + dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return VotingTreeVoteConfirmation( + vcTreePosition: dco_decode_u_64(arr[0]), + vanLeafPosition: dco_decode_opt_box_autoadd_u_32(arr[1]), + ); + } + @protected VotingVanWitness dco_decode_voting_van_witness(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -11138,6 +11643,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return ans_; } + @protected + List sse_decode_list_voting_round_session( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_voting_round_session(deserializer)); + } + return ans_; + } + @protected List sse_decode_list_voting_service_endpoint( SseDeserializer deserializer) { @@ -11191,6 +11709,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return ans_; } + @protected + List + sse_decode_list_voting_share_submission_payload( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_voting_share_submission_payload(deserializer)); + } + return ans_; + } + @protected List sse_decode_list_voting_share_workflow( SseDeserializer deserializer) { @@ -12164,7 +12696,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { // Codec=Sse (Serialization based), see doc to use other codecs var var_statusCode = sse_decode_u_16(deserializer); var var_body = sse_decode_String(deserializer); - return VotingChainResponse(statusCode: var_statusCode, body: var_body); + var var_retryAfterSecs = sse_decode_opt_box_autoadd_u_64(deserializer); + return VotingChainResponse( + statusCode: var_statusCode, + body: var_body, + retryAfterSecs: var_retryAfterSecs); } @protected @@ -12484,6 +13020,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { unconfirmedShareDelegations: var_unconfirmedShareDelegations); } + @protected + VotingRoundSession sse_decode_voting_round_session( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_roundId = sse_decode_String(deserializer); + var var_plan = sse_decode_voting_round_plan(deserializer); + var var_recovery = sse_decode_voting_round_recovery(deserializer); + var var_intents = sse_decode_list_voting_ballot_intent(deserializer); + return VotingRoundSession( + roundId: var_roundId, + plan: var_plan, + recovery: var_recovery, + intents: var_intents); + } + @protected VotingServiceEndpoint sse_decode_voting_service_endpoint( SseDeserializer deserializer) { @@ -12569,6 +13120,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { targetServers: var_targetServers); } + @protected + VotingShareSubmissionPayload sse_decode_voting_share_submission_payload( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_bundleIndex = sse_decode_u_32(deserializer); + var var_proposalId = sse_decode_u_32(deserializer); + var var_shareIndex = sse_decode_u_32(deserializer); + var var_vcTreePosition = sse_decode_opt_box_autoadd_u_64(deserializer); + return VotingShareSubmissionPayload( + bundleIndex: var_bundleIndex, + proposalId: var_proposalId, + shareIndex: var_shareIndex, + vcTreePosition: var_vcTreePosition); + } + @protected VotingShareTrackingSummary sse_decode_voting_share_tracking_summary( SseDeserializer deserializer) { @@ -12631,6 +13197,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { commitmentBundleJson: var_commitmentBundleJson); } + @protected + VotingTreeVoteConfirmation sse_decode_voting_tree_vote_confirmation( + SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_vcTreePosition = sse_decode_u_64(deserializer); + var var_vanLeafPosition = sse_decode_opt_box_autoadd_u_32(deserializer); + return VotingTreeVoteConfirmation( + vcTreePosition: var_vcTreePosition, + vanLeafPosition: var_vanLeafPosition); + } + @protected VotingVanWitness sse_decode_voting_van_witness(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -13807,6 +14384,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_list_voting_round_session( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_voting_round_session(item, serializer); + } + } + @protected void sse_encode_list_voting_service_endpoint( List self, SseSerializer serializer) { @@ -13847,6 +14434,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_list_voting_share_submission_payload( + List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_voting_share_submission_payload(item, serializer); + } + } + @protected void sse_encode_list_voting_share_workflow( List self, SseSerializer serializer) { @@ -14582,6 +15179,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { // Codec=Sse (Serialization based), see doc to use other codecs sse_encode_u_16(self.statusCode, serializer); sse_encode_String(self.body, serializer); + sse_encode_opt_box_autoadd_u_64(self.retryAfterSecs, serializer); } @protected @@ -14802,6 +15400,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { self.unconfirmedShareDelegations, serializer); } + @protected + void sse_encode_voting_round_session( + VotingRoundSession self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.roundId, serializer); + sse_encode_voting_round_plan(self.plan, serializer); + sse_encode_voting_round_recovery(self.recovery, serializer); + sse_encode_list_voting_ballot_intent(self.intents, serializer); + } + @protected void sse_encode_voting_service_endpoint( VotingServiceEndpoint self, SseSerializer serializer) { @@ -14858,6 +15466,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_String(self.targetServers, serializer); } + @protected + void sse_encode_voting_share_submission_payload( + VotingShareSubmissionPayload self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_u_32(self.bundleIndex, serializer); + sse_encode_u_32(self.proposalId, serializer); + sse_encode_u_32(self.shareIndex, serializer); + sse_encode_opt_box_autoadd_u_64(self.vcTreePosition, serializer); + } + @protected void sse_encode_voting_share_tracking_summary( VotingShareTrackingSummary self, SseSerializer serializer) { @@ -14896,6 +15514,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(self.commitmentBundleJson, serializer); } + @protected + void sse_encode_voting_tree_vote_confirmation( + VotingTreeVoteConfirmation self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_u_64(self.vcTreePosition, serializer); + sse_encode_opt_box_autoadd_u_32(self.vanLeafPosition, serializer); + } + @protected void sse_encode_voting_van_witness( VotingVanWitness self, SseSerializer serializer) { diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart index 1a4f93cc6..fd5cdffb6 100644 --- a/lib/src/rust/frb_generated.io.dart +++ b/lib/src/rust/frb_generated.io.dart @@ -420,6 +420,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_voting_round_info(dynamic raw); + @protected + List dco_decode_list_voting_round_session(dynamic raw); + @protected List dco_decode_list_voting_service_endpoint( dynamic raw); @@ -434,6 +437,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_voting_share_plan_item(dynamic raw); + @protected + List + dco_decode_list_voting_share_submission_payload(dynamic raw); + @protected List dco_decode_list_voting_share_workflow(dynamic raw); @@ -704,6 +711,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected VotingRoundRecovery dco_decode_voting_round_recovery(dynamic raw); + @protected + VotingRoundSession dco_decode_voting_round_session(dynamic raw); + @protected VotingServiceEndpoint dco_decode_voting_service_endpoint(dynamic raw); @@ -720,6 +730,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected VotingSharePlanItem dco_decode_voting_share_plan_item(dynamic raw); + @protected + VotingShareSubmissionPayload dco_decode_voting_share_submission_payload( + dynamic raw); + @protected VotingShareTrackingSummary dco_decode_voting_share_tracking_summary( dynamic raw); @@ -731,6 +745,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { VotingSignedVoteCommitment dco_decode_voting_signed_vote_commitment( dynamic raw); + @protected + VotingTreeVoteConfirmation dco_decode_voting_tree_vote_confirmation( + dynamic raw); + @protected VotingVanWitness dco_decode_voting_van_witness(dynamic raw); @@ -1138,6 +1156,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { List sse_decode_list_voting_round_info( SseDeserializer deserializer); + @protected + List sse_decode_list_voting_round_session( + SseDeserializer deserializer); + @protected List sse_decode_list_voting_service_endpoint( SseDeserializer deserializer); @@ -1155,6 +1177,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { List sse_decode_list_voting_share_plan_item( SseDeserializer deserializer); + @protected + List + sse_decode_list_voting_share_submission_payload( + SseDeserializer deserializer); + @protected List sse_decode_list_voting_share_workflow( SseDeserializer deserializer); @@ -1447,6 +1474,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { VotingRoundRecovery sse_decode_voting_round_recovery( SseDeserializer deserializer); + @protected + VotingRoundSession sse_decode_voting_round_session( + SseDeserializer deserializer); + @protected VotingServiceEndpoint sse_decode_voting_service_endpoint( SseDeserializer deserializer); @@ -1466,6 +1497,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { VotingSharePlanItem sse_decode_voting_share_plan_item( SseDeserializer deserializer); + @protected + VotingShareSubmissionPayload sse_decode_voting_share_submission_payload( + SseDeserializer deserializer); + @protected VotingShareTrackingSummary sse_decode_voting_share_tracking_summary( SseDeserializer deserializer); @@ -1478,6 +1513,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { VotingSignedVoteCommitment sse_decode_voting_signed_vote_commitment( SseDeserializer deserializer); + @protected + VotingTreeVoteConfirmation sse_decode_voting_tree_vote_confirmation( + SseDeserializer deserializer); + @protected VotingVanWitness sse_decode_voting_van_witness(SseDeserializer deserializer); @@ -1912,6 +1951,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { void sse_encode_list_voting_round_info( List self, SseSerializer serializer); + @protected + void sse_encode_list_voting_round_session( + List self, SseSerializer serializer); + @protected void sse_encode_list_voting_service_endpoint( List self, SseSerializer serializer); @@ -1928,6 +1971,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { void sse_encode_list_voting_share_plan_item( List self, SseSerializer serializer); + @protected + void sse_encode_list_voting_share_submission_payload( + List self, SseSerializer serializer); + @protected void sse_encode_list_voting_share_workflow( List self, SseSerializer serializer); @@ -2232,6 +2279,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { void sse_encode_voting_round_recovery( VotingRoundRecovery self, SseSerializer serializer); + @protected + void sse_encode_voting_round_session( + VotingRoundSession self, SseSerializer serializer); + @protected void sse_encode_voting_service_endpoint( VotingServiceEndpoint self, SseSerializer serializer); @@ -2252,6 +2303,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { void sse_encode_voting_share_plan_item( VotingSharePlanItem self, SseSerializer serializer); + @protected + void sse_encode_voting_share_submission_payload( + VotingShareSubmissionPayload self, SseSerializer serializer); + @protected void sse_encode_voting_share_tracking_summary( VotingShareTrackingSummary self, SseSerializer serializer); @@ -2264,6 +2319,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { void sse_encode_voting_signed_vote_commitment( VotingSignedVoteCommitment self, SseSerializer serializer); + @protected + void sse_encode_voting_tree_vote_confirmation( + VotingTreeVoteConfirmation self, SseSerializer serializer); + @protected void sse_encode_voting_van_witness( VotingVanWitness self, SseSerializer serializer); diff --git a/lib/src/rust/frb_generated.web.dart b/lib/src/rust/frb_generated.web.dart index 04b4a467a..fbeaa8738 100644 --- a/lib/src/rust/frb_generated.web.dart +++ b/lib/src/rust/frb_generated.web.dart @@ -422,6 +422,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_voting_round_info(dynamic raw); + @protected + List dco_decode_list_voting_round_session(dynamic raw); + @protected List dco_decode_list_voting_service_endpoint( dynamic raw); @@ -436,6 +439,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_voting_share_plan_item(dynamic raw); + @protected + List + dco_decode_list_voting_share_submission_payload(dynamic raw); + @protected List dco_decode_list_voting_share_workflow(dynamic raw); @@ -706,6 +713,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected VotingRoundRecovery dco_decode_voting_round_recovery(dynamic raw); + @protected + VotingRoundSession dco_decode_voting_round_session(dynamic raw); + @protected VotingServiceEndpoint dco_decode_voting_service_endpoint(dynamic raw); @@ -722,6 +732,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected VotingSharePlanItem dco_decode_voting_share_plan_item(dynamic raw); + @protected + VotingShareSubmissionPayload dco_decode_voting_share_submission_payload( + dynamic raw); + @protected VotingShareTrackingSummary dco_decode_voting_share_tracking_summary( dynamic raw); @@ -733,6 +747,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { VotingSignedVoteCommitment dco_decode_voting_signed_vote_commitment( dynamic raw); + @protected + VotingTreeVoteConfirmation dco_decode_voting_tree_vote_confirmation( + dynamic raw); + @protected VotingVanWitness dco_decode_voting_van_witness(dynamic raw); @@ -1140,6 +1158,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { List sse_decode_list_voting_round_info( SseDeserializer deserializer); + @protected + List sse_decode_list_voting_round_session( + SseDeserializer deserializer); + @protected List sse_decode_list_voting_service_endpoint( SseDeserializer deserializer); @@ -1157,6 +1179,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { List sse_decode_list_voting_share_plan_item( SseDeserializer deserializer); + @protected + List + sse_decode_list_voting_share_submission_payload( + SseDeserializer deserializer); + @protected List sse_decode_list_voting_share_workflow( SseDeserializer deserializer); @@ -1449,6 +1476,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { VotingRoundRecovery sse_decode_voting_round_recovery( SseDeserializer deserializer); + @protected + VotingRoundSession sse_decode_voting_round_session( + SseDeserializer deserializer); + @protected VotingServiceEndpoint sse_decode_voting_service_endpoint( SseDeserializer deserializer); @@ -1468,6 +1499,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { VotingSharePlanItem sse_decode_voting_share_plan_item( SseDeserializer deserializer); + @protected + VotingShareSubmissionPayload sse_decode_voting_share_submission_payload( + SseDeserializer deserializer); + @protected VotingShareTrackingSummary sse_decode_voting_share_tracking_summary( SseDeserializer deserializer); @@ -1480,6 +1515,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { VotingSignedVoteCommitment sse_decode_voting_signed_vote_commitment( SseDeserializer deserializer); + @protected + VotingTreeVoteConfirmation sse_decode_voting_tree_vote_confirmation( + SseDeserializer deserializer); + @protected VotingVanWitness sse_decode_voting_van_witness(SseDeserializer deserializer); @@ -1914,6 +1953,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { void sse_encode_list_voting_round_info( List self, SseSerializer serializer); + @protected + void sse_encode_list_voting_round_session( + List self, SseSerializer serializer); + @protected void sse_encode_list_voting_service_endpoint( List self, SseSerializer serializer); @@ -1930,6 +1973,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { void sse_encode_list_voting_share_plan_item( List self, SseSerializer serializer); + @protected + void sse_encode_list_voting_share_submission_payload( + List self, SseSerializer serializer); + @protected void sse_encode_list_voting_share_workflow( List self, SseSerializer serializer); @@ -2234,6 +2281,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { void sse_encode_voting_round_recovery( VotingRoundRecovery self, SseSerializer serializer); + @protected + void sse_encode_voting_round_session( + VotingRoundSession self, SseSerializer serializer); + @protected void sse_encode_voting_service_endpoint( VotingServiceEndpoint self, SseSerializer serializer); @@ -2254,6 +2305,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { void sse_encode_voting_share_plan_item( VotingSharePlanItem self, SseSerializer serializer); + @protected + void sse_encode_voting_share_submission_payload( + VotingShareSubmissionPayload self, SseSerializer serializer); + @protected void sse_encode_voting_share_tracking_summary( VotingShareTrackingSummary self, SseSerializer serializer); @@ -2266,6 +2321,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { void sse_encode_voting_signed_vote_commitment( VotingSignedVoteCommitment self, SseSerializer serializer); + @protected + void sse_encode_voting_tree_vote_confirmation( + VotingTreeVoteConfirmation self, SseSerializer serializer); + @protected void sse_encode_voting_van_witness( VotingVanWitness self, SseSerializer serializer); diff --git a/lib/store.dart b/lib/store.dart index c0b60e168..3eecdc802 100644 --- a/lib/store.dart +++ b/lib/store.dart @@ -14,6 +14,10 @@ import 'package:flutter/material.dart'; import 'package:zkool/main.dart'; import 'package:zkool/router.dart'; import 'package:zkool/services/block_height_service.dart'; +import 'package:zkool/services/votechain_backoff.dart'; +import 'package:zkool/services/votechain_classify.dart'; +import 'package:zkool/services/votechain_confirmation.dart'; +import 'package:zkool/services/votechain_failover.dart'; import 'package:zkool/src/rust/api/account.dart'; import 'package:zkool/src/rust/api/coin.dart'; import 'package:zkool/src/rust/api/contacts.dart'; @@ -463,6 +467,49 @@ class AppSettingsNotifier extends _$AppSettingsNotifier { voteNodeUrl: url, )); } + + /// Persist all settings to prefs/DB props and apply live coin changes + /// (lwd/transport/proxy). Called once when leaving the settings page. + Future save(AppSettings settings) async { + final c = coinContext.coin; + final prefs = SharedPreferencesAsync(); + await prefs.setString("database", settings.dbName); + await putProp(key: "is_light_node", value: settings.isLightNode.toString(), c: c); + await putProp(key: "lwd", value: settings.lwd, c: c); + await putProp(key: "block_explorer", value: settings.blockExplorer, c: c); + await putProp(key: "actions_per_sync", value: settings.actionsPerSync, c: c); + await putProp(key: "sync_interval", value: settings.syncInterval, c: c); + await prefs.setBool("pin_lock", settings.needPin); + await prefs.setBool("offline", settings.offline); + await prefs.setInt("transport", settings.transport); + await putProp(key: "proxy", value: settings.proxy, c: c); + await prefs.setBool("get_fx", settings.getFx); + await prefs.setString("coingecko", settings.coingecko); + await putProp(key: "qr_enabled", value: settings.qrSettings.enabled.toString(), c: c); + await putProp(key: "qr_size", value: settings.qrSettings.size.toString(), c: c); + await putProp(key: "qr_ecLevel", value: settings.qrSettings.ecLevel.toString(), c: c); + await putProp(key: "qr_delay", value: settings.qrSettings.delay.toString(), c: c); + await putProp(key: "qr_repair", value: settings.qrSettings.repair.toString(), c: c); + await prefs.setBool("vault", settings.vault); + await prefs.setBool("expert_mode", settings.expertMode); + await prefs.setString("palette_name", settings.paletteName); + await prefs.setBool("dark_mode", settings.darkMode); + await putProp(key: "currency", value: settings.currency, c: c); + await putProp(key: "voting_config_url", value: settings.votingConfigUrl, c: c); + await putProp(key: "vote_node_url", value: settings.voteNodeUrl, c: c); + coinContext.set( + coin: c + .setLwd(url: settings.lwd, serverType: settings.isLightNode ? 0 : 1) + .setTransport(transport: settings.transport) + .setProxy(proxy: settings.proxy), + ); + ref.read(priceProvider.notifier).setAutoFetchFx( + settings.getFx, + settings.coingecko, + settings.currency, + ); + state = AsyncValue.data(settings); + } } @Riverpod(keepAlive: true) @@ -1286,9 +1333,13 @@ class VotingSession extends _$VotingSession { Future _load() async { final c = coinContext.coin; + // Pass the draft proposal ids so the fork's plan can see open proposals: + // `needs_draft_setup` (the fresh-round trigger) and `all_decided` are + // computed against them, and are vacuously false/true with an empty list. + final draftIds = await _draftProposalIds(c); final plan = await votingPlan( roundId: _roundId, - proposalIds: const [], + proposalIds: draftIds, c: c, ); final recovery = await votingRecovery(roundId: _roundId, c: c); @@ -1296,6 +1347,19 @@ class VotingSession extends _$VotingSession { return VotingSessionState(plan: plan, recovery: recovery, intents: intents); } + Future> _draftProposalIds(Coin c) async { + try { + final drafts = await votingDraftsLoad(roundId: _roundId, c: c); + if (drafts == null || drafts.isEmpty) return const []; + return (jsonDecode(drafts) as List) + .map((d) => (d as Map)['proposal_id'] as int? ?? 0) + .where((id) => id > 0) + .toList(); + } on Exception { + return const []; + } + } + Future refresh() async { state = const AsyncValue.loading(); state = await AsyncValue.guard(_load); @@ -1309,8 +1373,147 @@ Future> votingRoundList(Ref ref) async { return await votingRounds(c: c); } +/// Sessions for every locally-known round, fetched in ONE Rust call that +/// holds a single pool connection (per-round loads would need one connection +/// per round and stall the pool once the page lists many rounds). +@riverpod +Future> votingSessionsAll(Ref ref) async { + final rounds = await ref.watch(votingRoundListProvider.future); + if (rounds.isEmpty) return const {}; + final c = coinContext.coin; + final sessions = await votingSessions( + roundIds: rounds.map((r) => r.roundId).toList(), + c: c, + ); + return { + for (final s in sessions) + s.roundId: VotingSessionState( + plan: s.plan, + recovery: s.recovery, + intents: s.intents, + ), + }; +} + +/// Friendly round title from the vote chain round status, falling back to +/// the round id. The chain's `title` field is the only friendly name source +/// (the config and the local DB carry no titles). +@riverpod +Future votingRoundTitle(Ref ref, String roundId, String chainUrl) async { + final c = coinContext.coin; + final res = await votechainRoundStatus( + baseUrl: chainUrl, + roundId: roundId, + c: c, + ); + if (res.statusCode < 200 || res.statusCode >= 300) return roundId; + final body = jsonDecode(res.body) as Map; + final round = body['round'] as Map? ?? {}; + final title = round['title']; + if (title is String && title.trim().isNotEmpty) return title; + return roundId; +} + +/// Normalized chain round status ("active" / "tallying" / "closed") from the +/// vote chain round status, or null when unresolved (non-2xx or absent). +/// Mirrors vizor's `votingPollListStatus`: numeric 1/2/3 plus lenient string +/// forms. Only "tallying"/"closed" rounds have a published tally. +@riverpod +Future votingRoundStatus(Ref ref, String roundId, String chainUrl) async { + final c = coinContext.coin; + final res = await votechainRoundStatus( + baseUrl: chainUrl, + roundId: roundId, + c: c, + ); + if (res.statusCode < 200 || res.statusCode >= 300) return null; + final body = jsonDecode(res.body) as Map; + final round = body['round'] as Map? ?? {}; + final status = round['status']; + if (status is int) { + return switch (status) { + 2 => "tallying", + 3 => "closed", + _ => "active", + }; + } + if (status is String) { + final s = status.trim().toLowerCase(); + if (s == '2' || s.contains('tally') || s == 'pending') return "tallying"; + if (s == '3' || + s.contains('closed') || + s.contains('complete') || + s.contains('done') || + s.contains('ended') || + s.contains('final') || + s.contains('result')) { + return "closed"; + } + return "active"; + } + return null; +} + +/// One round proposal with its option labels, from the vote chain round +/// status — used to render human-readable ballot evidence. +class VotingProposalInfo { + final int id; + final String title; + final Map optionLabels; + + const VotingProposalInfo({ + required this.id, + required this.title, + required this.optionLabels, + }); +} + +/// Parsed proposals (id, title, option id → label) for a round, from the +/// chain round status. Empty when the fetch fails. +@riverpod +Future> votingRoundProposals( + Ref ref, + String roundId, + String chainUrl, +) async { + final c = coinContext.coin; + final res = await votechainRoundStatus( + baseUrl: chainUrl, + roundId: roundId, + c: c, + ); + if (res.statusCode < 200 || res.statusCode >= 300) return const []; + final body = jsonDecode(res.body) as Map; + final round = body['round'] as Map? ?? {}; + final proposals = round['proposals'] as List? ?? []; + final result = []; + for (final p in proposals) { + if (p is! Map) continue; + final pid = p['id']; + if (pid is! int || pid < 1) continue; + final title = (p['title'] ?? "Proposal $pid").toString(); + var options = {}; + final opts = p['options'] as List? ?? []; + for (final entry in opts.asMap().entries) { + final o = entry.value; + if (o is! Map) continue; + final id = (o['index'] is int) ? o['index'] as int : entry.key; + options[id] = + (o['label'] ?? o['short_title'] ?? o['title'] ?? "Option").toString(); + } + if (options.isEmpty) { + // Vote-sdk default: Yes/No when options are missing. + options = const {0: "Yes", 1: "No"}; + } + result.add(VotingProposalInfo(id: pid, title: title, optionLabels: options)); + } + return result; +} + /// Resolved and authenticated voting config for the configured source URL. -/// Resolves fresh on build; on failure falls back to the last cached config. +/// `build()` returns the last cached resolved config without touching the +/// network (so merely reading the provider never triggers a fetch); call +/// `resolve()` to fetch fresh, falling back to cached on failure. @Riverpod(keepAlive: true) class VotingConfigNotifier extends _$VotingConfigNotifier { @override @@ -1318,25 +1521,38 @@ class VotingConfigNotifier extends _$VotingConfigNotifier { final settings = await ref.watch(appSettingsProvider.future); final source = settings.votingConfigUrl; if (source.isEmpty) return null; - return _resolve(source); + return votingConfigCached(source: source, c: coinContext.coin); } Future _resolve(String source) async { final c = coinContext.coin; try { return await votingConfigResolve(source: source, c: c); - } on Exception { - return await votingConfigCached(source: source, c: c); + } on AnyhowException catch (e) { + logger.e("Voting config resolve failed for $source: ${e.message}"); + final cached = await votingConfigCached(source: source, c: c); + if (cached != null) { + logger.w("Serving cached voting config for $source"); + return cached; + } + rethrow; } } - Future resolve() async { - final settings = await ref.read(appSettingsProvider.future); - final source = settings.votingConfigUrl; + /// Resolve the voting config, using [source] when provided or the + /// configured URL from app settings otherwise. + /// Throws when resolution fails and no cached config exists. + Future resolve({String? source}) async { + source ??= (await ref.read(appSettingsProvider.future)).votingConfigUrl; state = const AsyncValue.loading(); - final result = source.isEmpty ? null : await _resolve(source); - state = AsyncValue.data(result); - return result; + try { + final result = source.isEmpty ? null : await _resolve(source); + state = AsyncValue.data(result); + return result; + } catch (e, stackTrace) { + state = AsyncValue.error(e, stackTrace); + rethrow; + } } } @@ -1356,9 +1572,23 @@ class VotingSubmissionGuard extends _$VotingSubmissionGuard { @freezed sealed class VotingSubmissionJobState with _$VotingSubmissionJobState { factory VotingSubmissionJobState({ - required String stage, // idle|preparing|proving|submitting|confirming|done|error + required String stage, // idle|preparing|proving|submitting|confirming|voting|shares|retrying|done|error required double progress, String? error, + /// Voting weight (zatoshi) delegated by the prepared bundle, shown in + /// the status UI once the delegation prepare step completes. + BigInt? eligibleWeightZatoshi, + /// Chain evidence of what this run confirmed: the delegation tx hash, or + /// the first confirmed vote hash when no delegation ran. + String? txHash, + /// Block height at which [txHash] was included; set together with it. + int? confirmHeight, + /// Honest "done" headline describing what THIS run actually completed. + String? doneLabel, + /// Current automatic retry of a transiently failed run (1-based). + int? retryAttempt, + /// Seconds until the next automatic retry fires. + int? retryInSeconds, }) = _VotingSubmissionJobState; } @@ -1369,11 +1599,8 @@ sealed class VotingSubmissionJobState with _$VotingSubmissionJobState { /// run (`delegate` vs `poll_delegation`), never re-broadcasting a recorded tx. @Riverpod(keepAlive: true) class VotingSubmissionJob extends _$VotingSubmissionJob { - Timer? _shareTimer; - @override VotingSubmissionJobState build(String roundId) { - ref.onDispose(() => _shareTimer?.cancel()); return VotingSubmissionJobState(stage: "idle", progress: 0); } @@ -1394,38 +1621,104 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { if (state.stage != "idle" && state.stage != "error" && state.stage != "done") { - return; // already running + return; // already running (or waiting to retry) } - state = state.copyWith(stage: "running", progress: 0, error: null); + state = state.copyWith( + stage: "running", + progress: 0, + error: null, + retryAttempt: null, + retryInSeconds: null, + ); ref.read(votingSubmissionGuardProvider.notifier).setActive(true); - try { - await _runDelegation( - chainUrl: chainUrl, - pirServerUrl: pirServerUrl, - pirLayout: pirLayout, - roundParamsJson: roundParamsJson, - roundName: roundName, - maxRealNotesPerBundle: maxRealNotesPerBundle, - lightwalletdUrl: lightwalletdUrl, - ); - final session = await ref.read(votingSessionProvider(roundId).future); - await _runVotes( - chainUrl: chainUrl, - voteNodeUrl: voteNodeUrl, - plan: session.plan, - recovery: session.recovery, - ); - await _submitShares( - ceremonyStart: ceremonyStart, - voteEnd: voteEnd, - shareServerUrls: shareServerUrls, - singleShare: singleShare, - ); - state = state.copyWith(stage: "done", progress: 1); - ref.read(votingSubmissionGuardProvider.notifier).setActive(false); - } on Exception catch (e) { - state = state.copyWith(stage: "error", error: e.toString()); - ref.read(votingSubmissionGuardProvider.notifier).setActive(false); + // Every step below is plan-driven and idempotent, so re-running the body + // after a transient failure is always safe: recorded state is re-read and + // the plan decides what still needs doing. Transient failures retry with + // backoff; deterministic rejections fail fast to the error stage. + var attempt = 0; + while (true) { + try { + final chainUrls = await _effectiveChainUrls(chainUrl); + final failover = VoteChainFailover(allServers: chainUrls); + final delegated = await _runDelegation( + chainUrls: chainUrls, + failover: failover, + pirServerUrl: pirServerUrl, + pirLayout: pirLayout, + roundParamsJson: roundParamsJson, + roundName: roundName, + maxRealNotesPerBundle: maxRealNotesPerBundle, + lightwalletdUrl: lightwalletdUrl, + ); + final voted = await _runVotes( + chainUrls: chainUrls, + failover: failover, + voteNodeUrl: voteNodeUrl, + ); + // The vote chain servers double as helper (share) servers; the voting + // flow never passes shareServerUrls, so fall back to the configured + // vote servers (mirrors vizor's context.config.voteServers). + final shareUrls = await _effectiveShareServerUrls(shareServerUrls); + // The voting pages never pass ceremonyStart/voteEnd either — resolve + // them from the chain round status so the share plan can schedule. + var effectiveCeremony = ceremonyStart; + var effectiveVoteEnd = voteEnd; + if (effectiveCeremony == 0 || effectiveVoteEnd == null) { + final timing = + await _roundShareTiming(chainUrls: chainUrls, failover: failover); + if (timing != null) { + effectiveCeremony = timing.ceremonyStart; + effectiveVoteEnd = timing.voteEnd; + } + } + final shared = await _submitShares( + ceremonyStart: effectiveCeremony, + voteEnd: effectiveVoteEnd, + shareServerUrls: shareUrls, + singleShare: singleShare, + ); + final String doneLabel; + if (delegated) { + doneLabel = "Delegation confirmed"; + } else if (voted) { + doneLabel = "Votes submitted"; + } else if (shared) { + doneLabel = "Shares submitted"; + } else { + doneLabel = await _remainingLabel(); + } + state = state.copyWith(stage: "done", progress: 1, doneLabel: doneLabel); + ref.read(votingSubmissionGuardProvider.notifier).setActive(false); + // The round is now recorded locally and its plan advanced; refresh the + // voting page so the tile leaves "Join" and shows the real status + // without a manual refresh. + ref.invalidate(votingRoundListProvider); + ref.invalidate(votingSessionProvider(roundId)); + return; + } on TransientVoteChainException catch (e) { + attempt++; + if (attempt > voteChainMaxAutoRetries) { + state = state.copyWith( + stage: "error", + error: e.toString(), + retryAttempt: null, + retryInSeconds: null, + ); + ref.read(votingSubmissionGuardProvider.notifier).setActive(false); + return; + } + final delay = voteChainRetryDelay(attempt); + state = state.copyWith( + stage: "retrying", + retryAttempt: attempt, + retryInSeconds: delay.inSeconds, + ); + await Future.delayed(delay); + } on Exception catch (e) { + state = state.copyWith(stage: "error", error: e.toString()); + ref.read(votingSubmissionGuardProvider.notifier).setActive(false); + return; + } } } @@ -1433,8 +1726,27 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { state = VotingSubmissionJobState(stage: "idle", progress: 0); } - Future _runDelegation({ - required String chainUrl, + /// The vote chain front the caller chose first, followed by the remaining + /// configured vote servers — the failover candidate list for chain calls. + Future> _effectiveChainUrls(String chainUrl) async { + final urls = []; + if (chainUrl.isNotEmpty) urls.add(chainUrl); + try { + final config = await ref.read(votingConfigProvider.future); + for (final s in config?.voteServers ?? const []) { + if (!urls.contains(s.url)) urls.add(s.url); + } + } on Exception { + // Config unavailable: failover degrades to the single passed URL. + } + return urls; + } + + /// Runs the delegation steps for this round; returns true when a delegation + /// was confirmed in this run (fresh broadcast or recorded-hash poll). + Future _runDelegation({ + required List chainUrls, + required VoteChainFailover failover, required String pirServerUrl, VotingPirLayout? pirLayout, String? roundParamsJson, @@ -1449,57 +1761,77 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { final delegateStep = steps.where((s) => s.kind == "delegate").firstOrNull; final pollStep = steps.where((s) => s.kind == "poll_delegation").firstOrNull; - if (delegateStep == null && pollStep == null) { - return; // no delegation work for this round + // A fresh round has no plan steps at all; the fork's plan flags it with + // `needs_draft_setup`. Treat that as delegation work for the first bundle + // that still needs it (mirrors vizor's roundPlanNeedsDraftSetup trigger). + var freshDelegate = false; + int bundleIndex; + if (delegateStep != null || pollStep != null) { + bundleIndex = (delegateStep ?? pollStep!).bundleIndex; + } else { + final p = plan; + if (p == null || !p.needsDraftSetup) { + return false; // no delegation work for this round + } + final pending = p.delegationStatuses + .where((s) => s.phase == "prepared" || s.phase == "committed") + .firstOrNull; + if (p.delegationStatuses.isNotEmpty && pending == null) { + return false; // all bundles confirmed; _runVotes handles casting + } + bundleIndex = pending?.bundleIndex ?? 0; + freshDelegate = true; } - final bundleIndex = (delegateStep ?? pollStep!).bundleIndex; String? txHash; - if (delegateStep != null) { + if (delegateStep != null || freshDelegate) { state = state.copyWith(stage: "preparing"); - if (roundParamsJson != null && roundName != null) { - await delegationPrepare( - roundParamsJson: roundParamsJson, - roundName: roundName, - sessionJson: null, - bundleIndex: bundleIndex, - maxRealNotesPerBundle: maxRealNotesPerBundle, - lightwalletdUrl: lightwalletdUrl ?? "", - c: c, - ); - } else { - await delegationPrepareResume( - roundId: roundId, - bundleIndex: bundleIndex, - maxRealNotesPerBundle: maxRealNotesPerBundle, - lightwalletdUrl: lightwalletdUrl, - c: c, - ); + // The delegation keys embed an app-owned voting hotkey; auto-create + // one when missing and the round isn't already hotkey-bound (mirrors + // vizor's _ensureHotkey). A bound round without the stored hotkey + // keeps failing with the load error instead of silently generating a + // mismatched key. + if (!(plan?.hotkeyBound ?? false)) { + await _ensureVotingHotkey(); } - - final setup = await delegationSetup( - roundId: roundId, - bundleIndex: bundleIndex, - c: c, + // The voting pages never pass a lightwalletd URL — use the app's + // configured one for the fresh prepare (the fork needs it to fetch + // the snapshot anchor tree state). + final lwdUrl = (lightwalletdUrl == null || lightwalletdUrl!.isEmpty) + ? await _appLwdUrl() + : lightwalletdUrl!; + final prepared = roundParamsJson != null && roundName != null + ? await delegationPrepare( + roundParamsJson: roundParamsJson, + roundName: roundName, + sessionJson: null, + bundleIndex: bundleIndex, + maxRealNotesPerBundle: maxRealNotesPerBundle, + lightwalletdUrl: lwdUrl, + c: c, + ) + : await delegationPrepareResume( + roundId: roundId, + bundleIndex: bundleIndex, + maxRealNotesPerBundle: maxRealNotesPerBundle, + lightwalletdUrl: lightwalletdUrl, + c: c, + ); + state = state.copyWith( + eligibleWeightZatoshi: prepared.eligibleWeightZatoshi, ); state = state.copyWith(stage: "proving"); - final stream = delegationBuildSubmission( + final pir = await _resolvePirConfig( + pirServerUrl: pirServerUrl, + pirLayout: pirLayout, + ); + await _buildDelegation( roundId: roundId, bundleIndex: bundleIndex, - pcztBytes: setup.pcztBytes, - pirLayout: pirLayout, - pirServerUrl: pirServerUrl, - c: c, + pirLayout: pir.$2, + pirServerUrl: pir.$1, ); - await for (final event in stream) { - switch (event) { - case VotingDelegationProgress_ProofProgress(:final progress): - state = state.copyWith(progress: progress); - default: - break; - } - } // The FRB boundary drops the build result when a StreamSink is present, // so the wire body comes from the prop persisted by the build. @@ -1515,29 +1847,37 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { } state = state.copyWith(stage: "submitting"); - final res = await votechainSubmitDelegation( - baseUrl: chainUrl, - submissionJson: wireJson, - c: c, + final res = await failover.run( + baseUrls: chainUrls, + roundId: roundId, + call: (u) => votechainSubmitDelegation( + baseUrl: u, + submissionJson: wireJson, + c: c, + ), ); if (res.statusCode == 422) { throw AnyhowException( "Delegation rejected by the vote chain: ${res.body}", ); } + final submittedHash = txHashFromVoteChainBody(res.body) ?? ""; + final rejection = parseVoteChainRejection(res.body); if (res.statusCode < 200 || res.statusCode >= 300) { + // A 502 "broadcast outcome unknown" body carries the deterministic + // tx hash: the tx may have landed — record it and let confirmation + // polling learn the truth. Any other non-2xx without a hash fails. + if (submittedHash.isEmpty) { + throw AnyhowException( + "Vote chain submit failed (HTTP ${res.statusCode}): ${res.body}", + ); + } + } else if (rejection.code > 0 || submittedHash.isEmpty) { throw AnyhowException( - "Vote chain submit failed (HTTP ${res.statusCode}): ${res.body}", - ); - } - final result = jsonDecode(res.body) as Map; - txHash = result['tx_hash'] as String? ?? ""; - final code = result['code'] as int? ?? -1; - if (code != 0 || txHash.isEmpty) { - throw AnyhowException( - "Vote chain rejected the delegation: ${result['log'] ?? res.body}", + "Vote chain rejected the delegation: ${rejection.log}", ); } + txHash = submittedHash; await delegationMarkSubmitted( roundId: roundId, @@ -1560,82 +1900,556 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { } state = state.copyWith(stage: "confirming"); - final eventsJson = - await _pollTxConfirmation(chainUrl: chainUrl, txHash: txHash!); - await delegationConfirm( - roundId: roundId, - bundleIndex: bundleIndex, - txHash: txHash, - eventsJson: eventsJson, - c: c, - ); + final String recordedHash = txHash!; + int? confirmHeight; + try { + final conf = await _pollTxConfirmationWithFallback( + chainUrls: chainUrls, + failover: failover, + txHash: recordedHash, + wireJson: () async => + await delegationWireJson( + roundId: roundId, + bundleIndex: bundleIndex, + c: c, + ) ?? + "", + rebroadcast: () => failover.run( + baseUrls: chainUrls, + roundId: roundId, + call: (u) async { + final wire = await delegationWireJson( + roundId: roundId, + bundleIndex: bundleIndex, + c: c, + ); + if (wire == null || wire.isEmpty) { + throw AnyhowException( + "No delegation wire JSON to re-broadcast " + "round $roundId bundle $bundleIndex", + ); + } + return votechainSubmitDelegation( + baseUrl: u, + submissionJson: wire, + c: c, + ); + }, + ), + markSubmitted: (h) => delegationMarkSubmitted( + roundId: roundId, + bundleIndex: bundleIndex, + txHash: h, + c: c, + ), + ); + confirmHeight = conf.height; + await delegationConfirm( + roundId: roundId, + bundleIndex: bundleIndex, + txHash: recordedHash, + eventsJson: conf.eventsJson, + c: c, + ); + } on DuplicateNullifierOnResubmitException { + // The delegation committed on-chain but its hash was never recorded + // locally; recover the VAN leaf position from the commitment tree. + await _reconcileDelegation( + chainUrls: chainUrls, + bundleIndex: bundleIndex, + ); + } + state = state.copyWith(txHash: recordedHash, confirmHeight: confirmHeight); await ref.read(votingSessionProvider(roundId).notifier).refresh(); + // The done state must be backed by the fork's recorded confirmation: + // verify the bundle reads back as confirmed (tx hash or a + // commitment-tree-recovered VAN leaf) before claiming success — a stale + // or partial state must not show "Delegation confirmed". + final verified = await ref.read(votingSessionProvider(roundId).future); + final status = verified.plan?.delegationStatuses + .where((s) => s.bundleIndex == bundleIndex) + .firstOrNull; + if (status == null || status.phase != "confirmed") { + throw AnyhowException( + "Delegation confirmation was not recorded for " + "round $roundId bundle $bundleIndex", + ); + } + return true; } - Future _pollTxConfirmation({ - required String chainUrl, + /// Fills a missing PIR server URL / layout from the resolved voting config + /// (the UI never passes them — both push sites send "" / null). Falls back + /// to the passed values; when the config is unavailable the Rust side + /// errors with a clear message. + Future<(String, VotingPirLayout?)> _resolvePirConfig({ + required String pirServerUrl, + required VotingPirLayout? pirLayout, + }) async { + if (pirServerUrl.isNotEmpty && pirLayout != null) { + return (pirServerUrl, pirLayout); + } + try { + final config = await ref.read(votingConfigProvider.future); + final url = pirServerUrl.isNotEmpty + ? pirServerUrl + : (config?.pirServers.isNotEmpty ?? false) + ? config!.pirServers.first.url + : ""; + return (url, pirLayout ?? config?.pirLayout); + } on Exception { + return (pirServerUrl, pirLayout); + } + } + + /// Honest done label when a run performed no work: the plan may still have + /// pending steps (e.g. helper shares scheduled near the vote window) even + /// though nothing was due this run. + Future _remainingLabel() async { + final session = await ref.read(votingSessionProvider(roundId).future); + final pending = session.plan?.nextSteps ?? const []; + if (pending.isEmpty) return "All steps already confirmed"; + const shareSubmitKinds = {"submit_shares"}; + const shareConfirmKinds = {"confirm_share"}; + if (pending.every((s) => shareSubmitKinds.contains(s.kind))) { + return "Waiting for the share window"; + } + if (pending.every((s) => shareConfirmKinds.contains(s.kind))) { + return "Waiting for share confirmations"; + } + return "Waiting for the next step"; + } + + /// Resolves the round's ceremony start / vote end from the chain round + /// status when the flow didn't provide them (the voting pages never pass + /// them, and the share plan needs both to schedule submissions). Returns + /// null when the fetch fails or the fields are missing. + Future<({int ceremonyStart, int voteEnd})?> _roundShareTiming({ + required List chainUrls, + required VoteChainFailover failover, + }) async { + final c = coinContext.coin; + final VotingChainResponse res; + try { + res = await failover.run( + baseUrls: chainUrls, + roundId: roundId, + call: (u) => votechainRoundStatus( + baseUrl: u, + roundId: roundId, + c: c, + ), + ); + } on TransientVoteChainException { + return null; + } + if (res.statusCode < 200 || res.statusCode >= 300) return null; + final body = jsonDecode(res.body) as Map; + final round = body['round'] as Map? ?? {}; + final ceremony = round['ceremony_phase_start']; + final end = round['vote_end_time']; + if (ceremony is! int || end is! int) return null; + return (ceremonyStart: ceremony, voteEnd: end); + } + + /// The vote chain servers double as helper (share) servers. The voting + /// flow never passes `shareServerUrls`, so fall back to the configured + /// vote servers — mirrors vizor's `context.config.voteServers`. + Future> _effectiveShareServerUrls( + List shareServerUrls, + ) async { + if (shareServerUrls.isNotEmpty) return shareServerUrls; + try { + final config = await ref.read(votingConfigProvider.future); + return config?.voteServers.map((s) => s.url).toList() ?? const []; + } on Exception { + return const []; + } + } + + /// Returns the app-configured lightwalletd URL (settings `lwd`) — the + /// voting pages never pass one. Empty when unavailable; the fork then + /// errors with a clear message. + Future _appLwdUrl() async { + try { + return (await ref.read(appSettingsProvider.future)).lwd; + } on Exception { + return ""; + } + } + + /// Ensures a voting hotkey exists for this wallet. The caller must only + /// invoke this when the round is not yet hotkey-bound: creating a new key + /// for a bound round would mismatch the on-chain delegation. + Future _ensureVotingHotkey() async { + final c = coinContext.coin; + try { + await votingHotkeyGet(c: c); + } on AnyhowException { + await votingHotkeyCreate(c: c); + } + } + + /// Runs the delegation build/prove stream with the fork's software path: + /// the build re-runs `setup` internally (sampling fresh PCZT randomness), + /// so the app must NOT call `delegationSetup` separately and must pass + /// empty `pcztBytes` (skips the sighash consistency check). A restart + /// after a partial run leaves a stored sighash that the internal setup + /// refuses to overwrite — reset the unsigned setup and retry once + /// (mirrors vizor's keystone-stale-setup recovery). + Future _buildDelegation({ + required String roundId, + required int bundleIndex, + required VotingPirLayout? pirLayout, + required String pirServerUrl, + }) async { + final c = coinContext.coin; + debugPrint( + "Voting: delegation build starting for round $roundId bundle " + "$bundleIndex (no separate setup, empty pczt bytes)", + ); + for (var attempt = 0; attempt < 2; attempt++) { + try { + final stream = delegationBuildSubmission( + roundId: roundId, + bundleIndex: bundleIndex, + pcztBytes: const [], + pirLayout: pirLayout, + pirServerUrl: pirServerUrl, + c: c, + ); + await for (final event in stream) { + switch (event) { + case VotingDelegationProgress_ProofProgress(:final progress): + state = state.copyWith(progress: progress); + default: + break; + } + } + return; + } on AnyhowException catch (e) { + if (!e.message.toLowerCase().contains("refusing to overwrite")) { + rethrow; + } + if (attempt == 1) rethrow; + debugPrint( + "Voting: stale delegation setup during build for round $roundId " + "bundle $bundleIndex; resetting and retrying", + ); + await votingResetSessionState(roundId: roundId, c: c); + } + } + } + + /// Polls the vote chain until the tx is included in a block (HTTP 200 with + /// a positive `height`), returning the parsed confirmation. + /// + /// A recorded hash whose tx never confirms is re-broadcast with + /// byte-identical wire and polling continues: the chain derives the tx hash + /// from the payload, so the recorded hash stays valid, and server-side + /// dedup makes the re-broadcast harmless when the tx is merely slow. This + /// recovers a server crash that lost an accepted tx from its in-memory + /// mempool. A duplicate-nullifier 422 on the re-broadcast means the tx + /// committed successfully without the client learning its hash — the + /// caller reconciles from the commitment tree via + /// [DuplicateNullifierOnResubmitException]. + Future _pollTxConfirmationWithFallback({ + required List chainUrls, + required VoteChainFailover failover, required String txHash, + required Future Function() wireJson, + required Future Function() rebroadcast, + required Future Function(String txHash) markSubmitted, }) async { final c = coinContext.coin; - for (var attempt = 0; attempt < 45; attempt++) { - final res = await votechainTxConfirmation( - baseUrl: chainUrl, - txHash: txHash, - c: c, + Future pollWindow() async { + for (var attempt = 0; attempt < 45; attempt++) { + final res = await failover.run( + baseUrls: chainUrls, + roundId: roundId, + call: (u) => votechainTxConfirmation( + baseUrl: u, + txHash: txHash, + c: c, + ), + ); + if (res.statusCode == 200) { + final conf = parseVoteChainTxConfirmation(res.body); + if (conf != null) return conf; + } else if (res.statusCode == 422) { + // Included in a block but its execution failed — permanent. + final rejection = parseVoteChainRejection(res.body); + throw AnyhowException( + "The vote chain included tx $txHash but its execution " + "failed: ${rejection.log}", + ); + } + await Future.delayed(const Duration(seconds: 2)); + } + return null; + } + + final first = await pollWindow(); + if (first != null) return first; + + final rebuilt = await wireJson(); + if (rebuilt.isEmpty) { + throw AnyhowException( + "No wire JSON available to re-broadcast tx $txHash", ); - if (res.statusCode == 200) { - final body = jsonDecode(res.body) as Map; - final events = body['events']; - return jsonEncode(events ?? const []); + } + final res = await rebroadcast(); + if (res.statusCode == 422) { + final rejection = parseVoteChainRejection(res.body); + if (classifyVoteChainRejection(res.body) == + ChainRejectionKind.duplicateNullifier) { + throw DuplicateNullifierOnResubmitException( + "The vote chain says a nullifier in the re-broadcast of $txHash " + "was already spent: the submission committed on-chain but its " + "confirmation was never recorded locally. ${rejection.log}", + ); } - await Future.delayed(const Duration(seconds: 2)); + throw AnyhowException( + "Vote chain rejected the re-broadcast of $txHash: ${rejection.log}", + ); + } + if (res.statusCode < 200 || res.statusCode >= 300) { + throw TransientVoteChainException( + "Re-broadcast of $txHash failed (HTTP ${res.statusCode}): ${res.body}", + ); } - throw AnyhowException( + final rebroadcastHash = txHashFromVoteChainBody(res.body) ?? ""; + final code = parseVoteChainRejection(res.body).code; + if (code > 0) { + throw AnyhowException( + "Vote chain rejected the re-broadcast of $txHash: " + "${parseVoteChainRejection(res.body).log}", + ); + } + if (rebroadcastHash.isNotEmpty && rebroadcastHash != txHash) { + throw AnyhowException( + "Vote chain returned tx hash $rebroadcastHash for a byte-identical " + "re-broadcast of $txHash — refusing to record a mismatched hash", + ); + } + await markSubmitted(txHash); + + final second = await pollWindow(); + if (second != null) return second; + throw TransientVoteChainException( "Timed out waiting for tx $txHash to confirm", ); } + /// Recovers a delegation whose nullifier is spent on-chain but whose tx + /// hash was never recorded locally: locate the delegation's VAN commitment + /// (`gov_comm`) in the commitment tree and record the confirmation from the + /// recovered leaf position. + Future _reconcileDelegation({ + required List chainUrls, + required int bundleIndex, + }) async { + final c = coinContext.coin; + final vanHex = await votingDelegationVanCommitmentHex( + roundId: roundId, + bundleIndex: bundleIndex, + c: c, + ); + if (vanHex == null || vanHex.isEmpty) { + throw AnyhowException( + "The delegation for round $roundId bundle $bundleIndex is on-chain " + "but its commitment is not persisted — the wallet cannot locate it " + "in the commitment tree. Contact support.", + ); + } + BigInt? position; + for (final url in chainUrls) { + try { + position = await votingTreeFindLeaf( + roundId: roundId, + nodeUrl: url, + targetHex: vanHex, + ); + } on Exception { + continue; // this server's tree is unavailable; try the next + } + if (position != null) break; + } + if (position == null) { + throw AnyhowException( + "The chain says the delegation nullifier for round $roundId bundle " + "$bundleIndex was spent, but its commitment was not found in the " + "commitment tree. Contact support.", + ); + } + await votingRecoverConfirmDelegationFromTree( + roundId: roundId, + bundleIndex: bundleIndex, + vanLeafPosition: position.toInt(), + c: c, + ); + } + + /// Recovers a vote whose nullifier is spent on-chain but whose tx hash was + /// never recorded locally: locate the vote commitment in the commitment + /// tree and record the confirmation from the recovered leaf positions. + Future _reconcileVote({ + required List chainUrls, + required int bundleIndex, + required int proposalId, + }) async { + final c = coinContext.coin; + final targetHex = await votingVoteCommitmentHex( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + c: c, + ); + BigInt? vcPosition; + for (final url in chainUrls) { + try { + vcPosition = await votingTreeFindLeaf( + roundId: roundId, + nodeUrl: url, + targetHex: targetHex, + ); + } on Exception { + continue; + } + if (vcPosition != null) break; + } + if (vcPosition == null) { + throw AnyhowException( + "The chain says the vote nullifier for proposal $proposalId was " + "spent, but the vote commitment was not found in the commitment " + "tree. Contact support.", + ); + } + // The cast-vote tx appends the VAN output commitment immediately before + // the vote commitment (verified against the vote-sdk keeper), so the + // vote's VAN position is the vote commitment position minus one. + final van = vcPosition > BigInt.zero ? vcPosition - BigInt.one : null; + await votingRecoverConfirmVoteFromTree( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + vcTreePosition: vcPosition, + vanLeafPosition: van?.toInt(), + c: c, + ); + } + /// Casts and confirms the remaining votes for a round, recovery-first: /// `cast_vote` steps commit (streamed), `submit_vote` steps broadcast and - /// confirm, `poll_vote` steps only poll a previously recorded tx. - Future _runVotes({ - required String chainUrl, + /// confirm, `poll_vote` steps only poll a previously recorded tx. Ballot + /// intents are written from the drafts BEFORE the plan is read so a fresh + /// round's cast steps appear (mirrors vizor's writeBallotIntents). Returns + /// true when at least one vote was confirmed in this run. + Future _runVotes({ + required List chainUrls, + required VoteChainFailover failover, required String voteNodeUrl, - required VotingRoundPlan? plan, - required VotingRoundRecovery? recovery, }) async { - if (plan == null) return; + // An unset Vote Node URL falls back to the vote chain's REST API: the + // chain server also serves the commitment tree the VAN witnesses sync + // from (the polls page resolves the same default for the chain). + final resolvedVoteNodeUrl = voteNodeUrl.isNotEmpty + ? voteNodeUrl + : (chainUrls.isNotEmpty ? chainUrls.first : ""); final c = coinContext.coin; + + // Durable ballot intents first (mirrors vizor's writeBallotIntents): + // recovery can resume from the right choice if the app dies mid-vote. + // The round row exists by now (delegation prepared), so the FK holds. + final draftsJson = await votingDraftsLoad(roundId: roundId, c: c); + if (draftsJson != null && draftsJson.isNotEmpty) { + final drafts = jsonDecode(draftsJson) as List; + for (final d in drafts) { + final map = d as Map; + final proposalId = map['proposal_id'] as int? ?? 0; + final choice = map['choice'] as int? ?? 0; + final numOptions = map['num_options'] as int? ?? 2; + final skipped = choice == numOptions; + await votingSetBallotIntent( + roundId: roundId, + proposalId: proposalId, + skipped: skipped, + choice: skipped ? 0 : choice, + numOptions: numOptions, + c: c, + ); + } + } + + // Re-read the session: with intents written, the plan now carries the + // cast_vote steps (bundles exist after delegation). + await ref.read(votingSessionProvider(roundId).notifier).refresh(); + final session = await ref.read(votingSessionProvider(roundId).future); + final plan = session.plan; + final recovery = session.recovery; + if (plan == null) return false; + + // Defer votes for bundles whose delegation still needs to run (a fresh + // prepare creates rows for all policy bundles; each run advances one). + final delegateBundles = plan.nextSteps + .where((s) => s.kind == "delegate") + .map((s) => s.bundleIndex) + .toSet(); final voteSteps = plan.nextSteps - .where((s) => - s.kind == "cast_vote" || - s.kind == "submit_vote" || - s.kind == "poll_vote") + .where( + (s) => + (s.kind == "cast_vote" || + s.kind == "submit_vote" || + s.kind == "poll_vote") && + !delegateBundles.contains(s.bundleIndex), + ) .toList(); - if (voteSteps.isEmpty) return; - final draftsJson = await votingDraftsLoad(roundId: roundId, c: c); + if (voteSteps.isEmpty) return false; + var didWork = false; final byBundle = groupBy(voteSteps, (s) => s.bundleIndex); + // The commit step deserializes drafts as fork DraftVote, which requires + // vc_tree_position + single_share and rejects skipped choices and empty + // batches — sanitize the UI drafts for the cast call. + final commitDraftsJson = _sanitizedCommitDrafts(draftsJson); + for (final entry in byBundle.entries) { final bundleIndex = entry.key; final steps = entry.value; final castSteps = steps.where((s) => s.kind == "cast_vote").toList(); - if (castSteps.isNotEmpty) { - if (draftsJson == null || draftsJson.isEmpty) { + for (final step in castSteps) { + if (commitDraftsJson == null || commitDraftsJson.isEmpty) { throw AnyhowException( "No draft ballot saved for round $roundId; " "open the ballot and review first", ); } - state = state.copyWith(stage: "voting"); + // Cast ONE proposal at a time and submit it before the next cast: + // the fork derives the next VAN from the submission state (the + // proposal-authority mask clears per recorded tx_hash), so the VAN + // chaining only works when builds interleave with submissions + // (mirrors vizor's per-draft build loop). + final drafts = jsonDecode(commitDraftsJson) as List; + final draft = drafts + .where( + (d) => (d as Map)['proposal_id'] == + step.proposalId, + ) + .firstOrNull; + if (draft == null) { + throw AnyhowException( + "No draft for proposal ${step.proposalId} in round $roundId", + ); + } + state = state.copyWith(stage: "voting", progress: 0); final stream = votingCommitWithProgress( roundId: roundId, bundleIndex: bundleIndex, - draftsJson: draftsJson, - voteNodeUrl: voteNodeUrl, + draftsJson: jsonEncode([draft]), + voteNodeUrl: resolvedVoteNodeUrl, c: c, ); await for (final event in stream) { @@ -1646,58 +2460,23 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { break; } } - } - - for (final step in steps.where((s) => s.kind == "submit_vote")) { - state = state.copyWith(stage: "voting"); - final wireJson = await votingVoteWireJson( - roundId: roundId, + await _submitVote( bundleIndex: bundleIndex, proposalId: step.proposalId, - c: c, - ); - final res = await votechainSubmitVote( - baseUrl: chainUrl, - submissionJson: wireJson, - c: c, + chainUrls: chainUrls, + failover: failover, ); - if (res.statusCode == 422) { - throw AnyhowException( - "Vote rejected by the vote chain: ${res.body}", - ); - } - if (res.statusCode < 200 || res.statusCode >= 300) { - throw AnyhowException( - "Vote chain submit failed (HTTP ${res.statusCode}): ${res.body}", - ); - } - final result = jsonDecode(res.body) as Map; - final txHash = result['tx_hash'] as String? ?? ""; - final code = result['code'] as int? ?? -1; - if (code != 0 || txHash.isEmpty) { - throw AnyhowException( - "Vote chain rejected the vote: ${result['log'] ?? res.body}", - ); - } + didWork = true; + } - await votingMarkVoteSubmitted( - roundId: roundId, - bundleIndex: bundleIndex, - proposalId: step.proposalId, - txHash: txHash, - c: c, - ); - state = state.copyWith(stage: "confirming"); - final eventsJson = - await _pollTxConfirmation(chainUrl: chainUrl, txHash: txHash); - await votingConfirm( - roundId: roundId, + for (final step in steps.where((s) => s.kind == "submit_vote")) { + await _submitVote( bundleIndex: bundleIndex, proposalId: step.proposalId, - txHash: txHash, - eventsJson: eventsJson, - c: c, + chainUrls: chainUrls, + failover: failover, ); + didWork = true; } for (final step in steps.where((s) => s.kind == "poll_vote")) { @@ -1714,24 +2493,191 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { ); } state = state.copyWith(stage: "confirming"); - final eventsJson = - await _pollTxConfirmation(chainUrl: chainUrl, txHash: txHash); - await votingConfirm( - roundId: roundId, + final conf = await _pollVoteWithRecovery( + chainUrls: chainUrls, + failover: failover, bundleIndex: bundleIndex, proposalId: step.proposalId, txHash: txHash, - eventsJson: eventsJson, + ); + didWork = true; + if (state.txHash == null) { + state = state.copyWith(txHash: txHash, confirmHeight: conf?.height); + } + } + } + return didWork; + } + + /// Polls a previously recorded vote tx and confirms it, with the + /// re-broadcast and commitment-tree reconciliation fallbacks. Returns the + /// confirmation, or null when the vote was reconciled from the tree (no tx + /// events exist). + Future _pollVoteWithRecovery({ + required List chainUrls, + required VoteChainFailover failover, + required int bundleIndex, + required int proposalId, + required String txHash, + }) async { + final c = coinContext.coin; + try { + final conf = await _pollTxConfirmationWithFallback( + chainUrls: chainUrls, + failover: failover, + txHash: txHash, + wireJson: () => votingVoteWireJson( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + c: c, + ), + rebroadcast: () => failover.run( + baseUrls: chainUrls, + roundId: roundId, + call: (u) async { + final wire = await votingVoteWireJson( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + c: c, + ); + if (wire.isEmpty) { + throw AnyhowException( + "No vote wire JSON to re-broadcast proposal $proposalId", + ); + } + return votechainSubmitVote( + baseUrl: u, + submissionJson: wire, + c: c, + ); + }, + ), + markSubmitted: (h) => votingMarkVoteSubmitted( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + txHash: h, c: c, + ), + ); + await votingConfirm( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + txHash: txHash, + eventsJson: conf.eventsJson, + c: c, + ); + return conf; + } on DuplicateNullifierOnResubmitException { + await _reconcileVote( + chainUrls: chainUrls, + bundleIndex: bundleIndex, + proposalId: proposalId, + ); + return null; + } + } + + /// Broadcasts one committed vote and confirms it: rebuild the wire from + /// the persisted commitment, submit to the vote chain, record the tx hash, + /// poll until included in a block, and record the confirmation. Resubmits + /// are byte-identical (the wire is persisted) and confirmation polling has + /// the re-broadcast + commitment-tree reconciliation fallbacks. + Future _submitVote({ + required int bundleIndex, + required int proposalId, + required List chainUrls, + required VoteChainFailover failover, + }) async { + final c = coinContext.coin; + state = state.copyWith(stage: "voting", progress: 0); + final wireJson = await votingVoteWireJson( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + c: c, + ); + final res = await failover.run( + baseUrls: chainUrls, + roundId: roundId, + call: (u) => votechainSubmitVote( + baseUrl: u, + submissionJson: wireJson, + c: c, + ), + ); + if (res.statusCode == 422) { + throw AnyhowException( + "Vote rejected by the vote chain: ${res.body}", + ); + } + final submittedHash = txHashFromVoteChainBody(res.body) ?? ""; + final rejection = parseVoteChainRejection(res.body); + if (res.statusCode < 200 || res.statusCode >= 300) { + // A 502 "broadcast outcome unknown" body carries the deterministic + // tx hash: the tx may have landed — record it and let confirmation + // polling learn the truth. Any other non-2xx without a hash fails. + if (submittedHash.isEmpty) { + throw AnyhowException( + "Vote chain submit failed (HTTP ${res.statusCode}): ${res.body}", ); } + } else if (rejection.code > 0 || submittedHash.isEmpty) { + throw AnyhowException( + "Vote chain rejected the vote: ${rejection.log}", + ); + } + final txHash = submittedHash; + + await votingMarkVoteSubmitted( + roundId: roundId, + bundleIndex: bundleIndex, + proposalId: proposalId, + txHash: txHash, + c: c, + ); + state = state.copyWith(stage: "confirming"); + final conf = await _pollVoteWithRecovery( + chainUrls: chainUrls, + failover: failover, + bundleIndex: bundleIndex, + proposalId: proposalId, + txHash: txHash, + ); + if (state.txHash == null) { + state = state.copyWith(txHash: txHash, confirmHeight: conf?.height); } } + /// Converts UI drafts to the fork's DraftVote JSON for the commit step: + /// drops skipped choices (validation rejects choice == num_options) and + /// fills the fields the fork requires with no serde defaults. Returns null + /// when there is nothing to cast (no drafts or all-skipped ballot). + String? _sanitizedCommitDrafts(String? draftsJson) { + if (draftsJson == null || draftsJson.isEmpty) return null; + final drafts = jsonDecode(draftsJson) as List; + final commit = >[ + for (final d in drafts) + if ((d as Map)['choice'] != d['num_options']) + { + 'proposal_id': d['proposal_id'], + 'choice': d['choice'], + 'num_options': d['num_options'], + 'vc_tree_position': 0, + 'single_share': false, + }, + ]; + return commit.isEmpty ? null : jsonEncode(commit); + } + /// Plans and submits helper shares for unconfirmed share rows. With no /// active vote window or no helper servers configured this is a no-op /// (the real inputs arrive with the dynamic config in a later phase). - Future _submitShares({ + /// Returns true when at least one share was submitted and recorded. + Future _submitShares({ required int ceremonyStart, required int? voteEnd, required List shareServerUrls, @@ -1739,95 +2685,182 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { }) async { final c = coinContext.coin; state = state.copyWith(stage: "shares"); + var submitted = false; + final voteEndValue = voteEnd; + if (voteEndValue == null || shareServerUrls.isEmpty) { + return false; + } final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final sharePlan = await votingSharePlan( - roundId: roundId, + // First-pass source: the confirmed votes' share payloads — the share + // delegation rows only exist after a submission records them, so the + // old unconfirmed-row pairing could never start (mirrors vizor's + // commitment-driven share submission). + final payloads = await votingSharePayloads(roundId: roundId, c: c); + if (payloads.isEmpty) { + // All shares already recorded — a resume must not re-send them, but + // the tracker still needs to run to poll the helpers for + // confirmations. + final unconfirmed = await votingShareUnconfirmed(roundId: roundId, c: c); + if (unconfirmed.isNotEmpty) { + ref.read(votingShareTrackerProvider.notifier).arm( + roundId: roundId, + delaySeconds: 60, + ceremonyStart: ceremonyStart, + voteEnd: voteEndValue, + shareServerUrls: shareServerUrls, + singleShare: singleShare, + ); + } + return false; + } + final plans = await votingSharePlans( + shareCount: payloads.length, + serverUrls: shareServerUrls, now: BigInt.from(now), + voteEnd: BigInt.from(voteEndValue), ceremonyStart: BigInt.from(ceremonyStart), - voteEnd: voteEnd == null ? null : BigInt.from(voteEnd), - serverUrls: shareServerUrls, singleShare: singleShare, c: c, ); - final unconfirmed = await votingShareUnconfirmed(roundId: roundId, c: c); - final count = min(sharePlan.submissions.length, unconfirmed.length); - for (var i = 0; i < count; i++) { - final item = sharePlan.submissions[i]; - final share = unconfirmed[i]; + for (var i = 0; i < payloads.length && i < plans.length; i++) { + final payload = payloads[i]; + final plan = plans[i]; final wireJson = await votingShareWireJson( roundId: roundId, - bundleIndex: share.bundleIndex, - proposalId: share.proposalId, - shareIndex: share.shareIndex, - vcTreePosition: null, - submitAt: item.submitAt, + bundleIndex: payload.bundleIndex, + proposalId: payload.proposalId, + shareIndex: payload.shareIndex, + vcTreePosition: payload.vcTreePosition, + submitAt: plan.submitAt, c: c, ); final body = jsonEncode({...jsonDecode(wireJson), "vote_round_id": roundId}); - for (final server in item.targetServers) { + var failed = false; + for (final server in plan.targetServers) { final res = await votechainSubmitShare( serverUrl: server, payloadJson: body, c: c, ); if (res.statusCode < 200 || res.statusCode >= 300) { - throw AnyhowException( - "Share submit to $server failed " - "(HTTP ${res.statusCode}): ${res.body}", + debugPrint( + "Voting: share submit to $server failed " + "(HTTP ${res.statusCode}) — the tracker will retry", ); + failed = true; } } + // The vote itself is already confirmed on-chain; a helper outage must + // not fail the whole run. Unrecorded shares stay in the payload list + // and the tracker retries them while the round window is open. + if (failed) continue; await votingShareRecord( roundId: roundId, - bundleIndex: share.bundleIndex, - proposalId: share.proposalId, - shareIndex: share.shareIndex, - sentToUrls: item.targetServers, - submitAt: item.submitAt, + bundleIndex: payload.bundleIndex, + proposalId: payload.proposalId, + shareIndex: payload.shareIndex, + sentToUrls: plan.targetServers, + submitAt: plan.submitAt, c: c, ); + submitted = true; } - // Background tracking until every share confirms (or the vote window ends). - if (voteEnd != null && sharePlan.nextTrackingDelaySecs != null) { - _scheduleShareTracking( - delaySeconds: sharePlan.nextTrackingDelaySecs!.toInt(), - ceremonyStart: ceremonyStart, - voteEnd: voteEnd, - shareServerUrls: shareServerUrls, - singleShare: singleShare, - ); + // Background tracking until every share confirms (or the vote window + // ends). The tracker is session-independent: it survives client restarts + // via the polls page / splash re-arm hooks. + final tracker = ref.read(votingShareTrackerProvider.notifier); + tracker.arm( + roundId: roundId, + delaySeconds: voteChainShareTrackingDelay(plans, now), + ceremonyStart: ceremonyStart, + voteEnd: voteEndValue, + shareServerUrls: shareServerUrls, + singleShare: singleShare, + ); + return submitted; + } +} + +/// Delay until the next planned share submission (min positive +/// submitAt − now), capped at an hour; 60s when nothing is pending. +int voteChainShareTrackingDelay(List plans, int now) { + final nowBig = BigInt.from(now); + var minDelta = BigInt.zero; + var found = false; + for (final p in plans) { + final delta = p.submitAt - nowBig; + if (delta > BigInt.zero && (!found || delta < minDelta)) { + minDelta = delta; + found = true; } } + if (!found) return 60; + return minDelta > BigInt.from(3600) ? 3600 : minDelta.toInt(); +} - void _scheduleShareTracking({ +/// Session-independent helper-share tracking for one round. +/// +/// Armed by the submission job, the voting polls page, and the post-wallet- +/// open hook, so a client restart does not strand share delivery while the +/// round window is open. Each tick submits unrecorded share payloads +/// (a helper outage during the foreground run leaves them unrecorded), polls +/// helper status for sent shares, and resubmits overdue ones — best-effort, +/// with the next tick retrying after any failure. +@Riverpod(keepAlive: true) +class VotingShareTracker extends _$VotingShareTracker { + final Map _timers = {}; + + @override + bool build() { + ref.onDispose(() { + for (final timer in _timers.values) { + timer.cancel(); + } + _timers.clear(); + }); + return false; // needsAttention flag + } + + /// Sets the "share delivery pending" attention flag shown by the voting + /// page banner. + void markAttention(bool attention) { + state = attention; + } + + /// Arms (or re-arms) the tracking timer for a round. + void arm({ + required String roundId, required int delaySeconds, required int ceremonyStart, required int? voteEnd, required List shareServerUrls, required bool singleShare, }) { - _shareTimer?.cancel(); - _shareTimer = Timer(Duration(seconds: delaySeconds), () async { - if (state.stage == "done" || state.stage == "error") { - try { - await _trackShares( - ceremonyStart: ceremonyStart, - voteEnd: voteEnd, - shareServerUrls: shareServerUrls, - singleShare: singleShare, - ); - } on Exception catch (_) { - // The next tick retries; share tracking is best-effort. - } + _timers[roundId]?.cancel(); + _timers[roundId] = Timer(Duration(seconds: delaySeconds), () async { + _timers.remove(roundId); + try { + final pending = await tick( + roundId: roundId, + ceremonyStart: ceremonyStart, + voteEnd: voteEnd, + shareServerUrls: shareServerUrls, + singleShare: singleShare, + ); + state = pending; + } on Exception { + // The next tick retries; share tracking is best-effort. } }); } - /// One share-tracking tick: poll helper status for sent shares, resubmit - /// when the plan reports overdue shares, then re-arm if work remains. - Future _trackShares({ + /// One share-tracking tick: submit unrecorded payloads, poll helper status + /// for sent shares, resubmit overdue shares, then re-arm if work remains. + /// Returns whether work is still pending after the tick. + Future tick({ + required String roundId, required int ceremonyStart, required int? voteEnd, required List shareServerUrls, @@ -1835,6 +2868,63 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { }) async { final c = coinContext.coin; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + var pending = false; + + // Submit shares whose rows were never recorded (all-target helper + // failure in the foreground run, or a client crash before recording). + // `votingSharePayloads` excludes recorded shares, so this never re-sends. + final payloads = await votingSharePayloads(roundId: roundId, c: c); + if (payloads.isNotEmpty && voteEnd != null) { + final plans = await votingSharePlans( + shareCount: payloads.length, + serverUrls: shareServerUrls, + now: BigInt.from(now), + voteEnd: BigInt.from(voteEnd), + ceremonyStart: BigInt.from(ceremonyStart), + singleShare: singleShare, + c: c, + ); + for (var i = 0; i < payloads.length && i < plans.length; i++) { + final payload = payloads[i]; + final plan = plans[i]; + final wireJson = await votingShareWireJson( + roundId: roundId, + bundleIndex: payload.bundleIndex, + proposalId: payload.proposalId, + shareIndex: payload.shareIndex, + vcTreePosition: payload.vcTreePosition, + submitAt: plan.submitAt, + c: c, + ); + final body = + jsonEncode({...jsonDecode(wireJson), "vote_round_id": roundId}); + var failed = false; + for (final server in plan.targetServers) { + final res = await votechainSubmitShare( + serverUrl: server, + payloadJson: body, + c: c, + ); + if (res.statusCode < 200 || res.statusCode >= 300) { + failed = true; + } + } + if (failed) { + pending = true; + continue; + } + await votingShareRecord( + roundId: roundId, + bundleIndex: payload.bundleIndex, + proposalId: payload.proposalId, + shareIndex: payload.shareIndex, + sentToUrls: plan.targetServers, + submitAt: plan.submitAt, + c: c, + ); + } + } + final plan = await votingSharePlan( roundId: roundId, now: BigInt.from(now), @@ -1845,26 +2935,34 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { c: c, ); final unconfirmed = await votingShareUnconfirmed(roundId: roundId, c: c); - if (unconfirmed.isEmpty) return; + if (unconfirmed.isEmpty && !pending) return false; for (final share in unconfirmed) { if (share.sentToUrls.isEmpty) continue; final shareId = hex.encode(share.nullifier); - final res = await votechainShareStatus( - serverUrl: share.sentToUrls.first, - roundId: roundId, - shareId: shareId, - c: c, - ); - if (res.statusCode == 200) { - await votingShareConfirm( + // Try every helper the share was sent to, not just the first: a dead + // helper must not hide a confirmation another helper can report. + var confirmed = false; + for (final server in share.sentToUrls) { + final res = await votechainShareStatus( + serverUrl: server, roundId: roundId, - bundleIndex: share.bundleIndex, - proposalId: share.proposalId, - shareIndex: share.shareIndex, + shareId: shareId, c: c, ); + if (res.statusCode == 200) { + await votingShareConfirm( + roundId: roundId, + bundleIndex: share.bundleIndex, + proposalId: share.proposalId, + shareIndex: share.shareIndex, + c: c, + ); + confirmed = true; + break; + } } + if (!confirmed) pending = true; } if (plan.summary.overdue > BigInt.zero) { @@ -1890,10 +2988,7 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { c: c, ); if (res.statusCode < 200 || res.statusCode >= 300) { - throw AnyhowException( - "Share resubmit to $server failed " - "(HTTP ${res.statusCode}): ${res.body}", - ); + pending = true; } } await votingShareAddServers( @@ -1908,7 +3003,8 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { } if (voteEnd != null && plan.nextTrackingDelaySecs != null) { - _scheduleShareTracking( + arm( + roundId: roundId, delaySeconds: plan.nextTrackingDelaySecs!.toInt(), ceremonyStart: ceremonyStart, voteEnd: voteEnd, @@ -1916,5 +3012,72 @@ class VotingSubmissionJob extends _$VotingSubmissionJob { singleShare: singleShare, ); } + // Reflect the confirmations in the plan so the round tile and status + // screens update ("Resume" -> "View results" once every share confirms). + await ref.read(votingSessionProvider(roundId).notifier).refresh(); + return pending; + } +} + +/// Re-arms share tracking for every local round whose plan still has share +/// work (submissions or confirmations). Called when the voting page opens and +/// after the wallet unlocks, so a client restart does not strand helper-share +/// delivery while the round window is open. +Future armShareTrackingForPendingRounds(WidgetRef ref) async { + final c = coinContext.coin; + final Map sessions; + try { + sessions = await ref.read(votingSessionsAllProvider.future); + } on Exception { + return; // wallet/rounds unavailable + } + List serverUrls; + try { + final config = await ref.read(votingConfigProvider.future); + serverUrls = config?.voteServers.map((s) => s.url).toList() ?? const []; + } on Exception { + serverUrls = const []; + } + if (serverUrls.isEmpty) return; + + var anyPending = false; + for (final entry in sessions.entries) { + final roundId = entry.key; + final steps = entry.value.plan?.nextSteps ?? const []; + final hasShareWork = + steps.any((s) => s.kind == "submit_shares" || s.kind == "confirm_share"); + if (!hasShareWork) continue; + // Resolve the round window from the chain so the share plan can schedule. + var ceremonyStart = 0; + int? voteEnd; + try { + final res = await votechainRoundStatus( + baseUrl: serverUrls.first, + roundId: roundId, + c: c, + ); + if (res.statusCode >= 200 && res.statusCode < 300) { + final body = jsonDecode(res.body) as Map; + final round = body['round'] as Map? ?? {}; + final ceremony = round['ceremony_phase_start']; + final end = round['vote_end_time']; + if (ceremony is int) ceremonyStart = ceremony; + if (end is int) voteEnd = end; + } + } on Exception { + // Timing unavailable: arm anyway; the tick resolves it on retry. + } + ref.read(votingShareTrackerProvider.notifier).arm( + roundId: roundId, + delaySeconds: 60, + ceremonyStart: ceremonyStart, + voteEnd: voteEnd, + shareServerUrls: serverUrls, + singleShare: false, + ); + anyPending = true; + } + if (anyPending) { + ref.read(votingShareTrackerProvider.notifier).markAttention(true); } } diff --git a/lib/store.freezed.dart b/lib/store.freezed.dart index 2ecb9bcaf..b416a86da 100644 --- a/lib/store.freezed.dart +++ b/lib/store.freezed.dart @@ -5049,10 +5049,31 @@ class __$VotingSessionStateCopyWithImpl<$Res> /// @nodoc mixin _$VotingSubmissionJobState { - String get stage; // idle|preparing|proving|submitting|confirming|done|error + String + get stage; // idle|preparing|proving|submitting|confirming|voting|shares|retrying|done|error double get progress; String? get error; + /// Voting weight (zatoshi) delegated by the prepared bundle, shown in + /// the status UI once the delegation prepare step completes. + BigInt? get eligibleWeightZatoshi; + + /// Chain evidence of what this run confirmed: the delegation tx hash, or + /// the first confirmed vote hash when no delegation ran. + String? get txHash; + + /// Block height at which [txHash] was included; set together with it. + int? get confirmHeight; + + /// Honest "done" headline describing what THIS run actually completed. + String? get doneLabel; + + /// Current automatic retry of a transiently failed run (1-based). + int? get retryAttempt; + + /// Seconds until the next automatic retry fires. + int? get retryInSeconds; + /// Create a copy of VotingSubmissionJobState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -5069,15 +5090,36 @@ mixin _$VotingSubmissionJobState { (identical(other.stage, stage) || other.stage == stage) && (identical(other.progress, progress) || other.progress == progress) && - (identical(other.error, error) || other.error == error)); + (identical(other.error, error) || other.error == error) && + (identical(other.eligibleWeightZatoshi, eligibleWeightZatoshi) || + other.eligibleWeightZatoshi == eligibleWeightZatoshi) && + (identical(other.txHash, txHash) || other.txHash == txHash) && + (identical(other.confirmHeight, confirmHeight) || + other.confirmHeight == confirmHeight) && + (identical(other.doneLabel, doneLabel) || + other.doneLabel == doneLabel) && + (identical(other.retryAttempt, retryAttempt) || + other.retryAttempt == retryAttempt) && + (identical(other.retryInSeconds, retryInSeconds) || + other.retryInSeconds == retryInSeconds)); } @override - int get hashCode => Object.hash(runtimeType, stage, progress, error); + int get hashCode => Object.hash( + runtimeType, + stage, + progress, + error, + eligibleWeightZatoshi, + txHash, + confirmHeight, + doneLabel, + retryAttempt, + retryInSeconds); @override String toString() { - return 'VotingSubmissionJobState(stage: $stage, progress: $progress, error: $error)'; + return 'VotingSubmissionJobState(stage: $stage, progress: $progress, error: $error, eligibleWeightZatoshi: $eligibleWeightZatoshi, txHash: $txHash, confirmHeight: $confirmHeight, doneLabel: $doneLabel, retryAttempt: $retryAttempt, retryInSeconds: $retryInSeconds)'; } } @@ -5087,7 +5129,16 @@ abstract mixin class $VotingSubmissionJobStateCopyWith<$Res> { $Res Function(VotingSubmissionJobState) _then) = _$VotingSubmissionJobStateCopyWithImpl; @useResult - $Res call({String stage, double progress, String? error}); + $Res call( + {String stage, + double progress, + String? error, + BigInt? eligibleWeightZatoshi, + String? txHash, + int? confirmHeight, + String? doneLabel, + int? retryAttempt, + int? retryInSeconds}); } /// @nodoc @@ -5106,6 +5157,12 @@ class _$VotingSubmissionJobStateCopyWithImpl<$Res> Object? stage = null, Object? progress = null, Object? error = freezed, + Object? eligibleWeightZatoshi = freezed, + Object? txHash = freezed, + Object? confirmHeight = freezed, + Object? doneLabel = freezed, + Object? retryAttempt = freezed, + Object? retryInSeconds = freezed, }) { return _then(_self.copyWith( stage: null == stage @@ -5120,6 +5177,30 @@ class _$VotingSubmissionJobStateCopyWithImpl<$Res> ? _self.error : error // ignore: cast_nullable_to_non_nullable as String?, + eligibleWeightZatoshi: freezed == eligibleWeightZatoshi + ? _self.eligibleWeightZatoshi + : eligibleWeightZatoshi // ignore: cast_nullable_to_non_nullable + as BigInt?, + txHash: freezed == txHash + ? _self.txHash + : txHash // ignore: cast_nullable_to_non_nullable + as String?, + confirmHeight: freezed == confirmHeight + ? _self.confirmHeight + : confirmHeight // ignore: cast_nullable_to_non_nullable + as int?, + doneLabel: freezed == doneLabel + ? _self.doneLabel + : doneLabel // ignore: cast_nullable_to_non_nullable + as String?, + retryAttempt: freezed == retryAttempt + ? _self.retryAttempt + : retryAttempt // ignore: cast_nullable_to_non_nullable + as int?, + retryInSeconds: freezed == retryInSeconds + ? _self.retryInSeconds + : retryInSeconds // ignore: cast_nullable_to_non_nullable + as int?, )); } } @@ -5215,13 +5296,32 @@ extension VotingSubmissionJobStatePatterns on VotingSubmissionJobState { @optionalTypeArgs TResult maybeWhen( - TResult Function(String stage, double progress, String? error)? $default, { + TResult Function( + String stage, + double progress, + String? error, + BigInt? eligibleWeightZatoshi, + String? txHash, + int? confirmHeight, + String? doneLabel, + int? retryAttempt, + int? retryInSeconds)? + $default, { required TResult orElse(), }) { final _that = this; switch (_that) { case _VotingSubmissionJobState() when $default != null: - return $default(_that.stage, _that.progress, _that.error); + return $default( + _that.stage, + _that.progress, + _that.error, + _that.eligibleWeightZatoshi, + _that.txHash, + _that.confirmHeight, + _that.doneLabel, + _that.retryAttempt, + _that.retryInSeconds); case _: return orElse(); } @@ -5242,12 +5342,31 @@ extension VotingSubmissionJobStatePatterns on VotingSubmissionJobState { @optionalTypeArgs TResult when( - TResult Function(String stage, double progress, String? error) $default, + TResult Function( + String stage, + double progress, + String? error, + BigInt? eligibleWeightZatoshi, + String? txHash, + int? confirmHeight, + String? doneLabel, + int? retryAttempt, + int? retryInSeconds) + $default, ) { final _that = this; switch (_that) { case _VotingSubmissionJobState(): - return $default(_that.stage, _that.progress, _that.error); + return $default( + _that.stage, + _that.progress, + _that.error, + _that.eligibleWeightZatoshi, + _that.txHash, + _that.confirmHeight, + _that.doneLabel, + _that.retryAttempt, + _that.retryInSeconds); } } @@ -5265,12 +5384,31 @@ extension VotingSubmissionJobStatePatterns on VotingSubmissionJobState { @optionalTypeArgs TResult? whenOrNull( - TResult? Function(String stage, double progress, String? error)? $default, + TResult? Function( + String stage, + double progress, + String? error, + BigInt? eligibleWeightZatoshi, + String? txHash, + int? confirmHeight, + String? doneLabel, + int? retryAttempt, + int? retryInSeconds)? + $default, ) { final _that = this; switch (_that) { case _VotingSubmissionJobState() when $default != null: - return $default(_that.stage, _that.progress, _that.error); + return $default( + _that.stage, + _that.progress, + _that.error, + _that.eligibleWeightZatoshi, + _that.txHash, + _that.confirmHeight, + _that.doneLabel, + _that.retryAttempt, + _that.retryInSeconds); case _: return null; } @@ -5281,16 +5419,50 @@ extension VotingSubmissionJobStatePatterns on VotingSubmissionJobState { class _VotingSubmissionJobState implements VotingSubmissionJobState { _VotingSubmissionJobState( - {required this.stage, required this.progress, this.error}); + {required this.stage, + required this.progress, + this.error, + this.eligibleWeightZatoshi, + this.txHash, + this.confirmHeight, + this.doneLabel, + this.retryAttempt, + this.retryInSeconds}); @override final String stage; -// idle|preparing|proving|submitting|confirming|done|error +// idle|preparing|proving|submitting|confirming|voting|shares|retrying|done|error @override final double progress; @override final String? error; + /// Voting weight (zatoshi) delegated by the prepared bundle, shown in + /// the status UI once the delegation prepare step completes. + @override + final BigInt? eligibleWeightZatoshi; + + /// Chain evidence of what this run confirmed: the delegation tx hash, or + /// the first confirmed vote hash when no delegation ran. + @override + final String? txHash; + + /// Block height at which [txHash] was included; set together with it. + @override + final int? confirmHeight; + + /// Honest "done" headline describing what THIS run actually completed. + @override + final String? doneLabel; + + /// Current automatic retry of a transiently failed run (1-based). + @override + final int? retryAttempt; + + /// Seconds until the next automatic retry fires. + @override + final int? retryInSeconds; + /// Create a copy of VotingSubmissionJobState /// with the given fields replaced by the non-null parameter values. @override @@ -5308,15 +5480,36 @@ class _VotingSubmissionJobState implements VotingSubmissionJobState { (identical(other.stage, stage) || other.stage == stage) && (identical(other.progress, progress) || other.progress == progress) && - (identical(other.error, error) || other.error == error)); + (identical(other.error, error) || other.error == error) && + (identical(other.eligibleWeightZatoshi, eligibleWeightZatoshi) || + other.eligibleWeightZatoshi == eligibleWeightZatoshi) && + (identical(other.txHash, txHash) || other.txHash == txHash) && + (identical(other.confirmHeight, confirmHeight) || + other.confirmHeight == confirmHeight) && + (identical(other.doneLabel, doneLabel) || + other.doneLabel == doneLabel) && + (identical(other.retryAttempt, retryAttempt) || + other.retryAttempt == retryAttempt) && + (identical(other.retryInSeconds, retryInSeconds) || + other.retryInSeconds == retryInSeconds)); } @override - int get hashCode => Object.hash(runtimeType, stage, progress, error); + int get hashCode => Object.hash( + runtimeType, + stage, + progress, + error, + eligibleWeightZatoshi, + txHash, + confirmHeight, + doneLabel, + retryAttempt, + retryInSeconds); @override String toString() { - return 'VotingSubmissionJobState(stage: $stage, progress: $progress, error: $error)'; + return 'VotingSubmissionJobState(stage: $stage, progress: $progress, error: $error, eligibleWeightZatoshi: $eligibleWeightZatoshi, txHash: $txHash, confirmHeight: $confirmHeight, doneLabel: $doneLabel, retryAttempt: $retryAttempt, retryInSeconds: $retryInSeconds)'; } } @@ -5328,7 +5521,16 @@ abstract mixin class _$VotingSubmissionJobStateCopyWith<$Res> __$VotingSubmissionJobStateCopyWithImpl; @override @useResult - $Res call({String stage, double progress, String? error}); + $Res call( + {String stage, + double progress, + String? error, + BigInt? eligibleWeightZatoshi, + String? txHash, + int? confirmHeight, + String? doneLabel, + int? retryAttempt, + int? retryInSeconds}); } /// @nodoc @@ -5347,6 +5549,12 @@ class __$VotingSubmissionJobStateCopyWithImpl<$Res> Object? stage = null, Object? progress = null, Object? error = freezed, + Object? eligibleWeightZatoshi = freezed, + Object? txHash = freezed, + Object? confirmHeight = freezed, + Object? doneLabel = freezed, + Object? retryAttempt = freezed, + Object? retryInSeconds = freezed, }) { return _then(_VotingSubmissionJobState( stage: null == stage @@ -5361,6 +5569,30 @@ class __$VotingSubmissionJobStateCopyWithImpl<$Res> ? _self.error : error // ignore: cast_nullable_to_non_nullable as String?, + eligibleWeightZatoshi: freezed == eligibleWeightZatoshi + ? _self.eligibleWeightZatoshi + : eligibleWeightZatoshi // ignore: cast_nullable_to_non_nullable + as BigInt?, + txHash: freezed == txHash + ? _self.txHash + : txHash // ignore: cast_nullable_to_non_nullable + as String?, + confirmHeight: freezed == confirmHeight + ? _self.confirmHeight + : confirmHeight // ignore: cast_nullable_to_non_nullable + as int?, + doneLabel: freezed == doneLabel + ? _self.doneLabel + : doneLabel // ignore: cast_nullable_to_non_nullable + as String?, + retryAttempt: freezed == retryAttempt + ? _self.retryAttempt + : retryAttempt // ignore: cast_nullable_to_non_nullable + as int?, + retryInSeconds: freezed == retryInSeconds + ? _self.retryInSeconds + : retryInSeconds // ignore: cast_nullable_to_non_nullable + as int?, )); } } diff --git a/lib/store.g.dart b/lib/store.g.dart index b56267cda..7f0c39bd6 100644 --- a/lib/store.g.dart +++ b/lib/store.g.dart @@ -619,7 +619,7 @@ final class AppSettingsNotifierProvider } String _$appSettingsNotifierHash() => - r'7772fb1c14262ec8f76f7d44c600d06358ec5ebb'; + r'2892f9bcc456a390e78cdbc18d88bd913c00158c'; abstract class _$AppSettingsNotifier extends $AsyncNotifier { FutureOr build(); @@ -1463,7 +1463,7 @@ final class VotingSessionProvider } } -String _$votingSessionHash() => r'74d90be6f0517caae693468712cabce9319f0584'; +String _$votingSessionHash() => r'194096c377208865fd439826f7355d88d99e93ff'; /// Per-round voting session. `build()` is the recovery-first triple load; /// `refresh()` re-runs it after an action mutates the voting DB. @@ -1563,18 +1563,412 @@ final class VotingRoundListProvider extends $FunctionalProvider< String _$votingRoundListHash() => r'12d2cfda4753a04d9343e21a6637789106c3ffb5'; +/// Sessions for every locally-known round, fetched in ONE Rust call that +/// holds a single pool connection (per-round loads would need one connection +/// per round and stall the pool once the page lists many rounds). + +@ProviderFor(votingSessionsAll) +const votingSessionsAllProvider = VotingSessionsAllProvider._(); + +/// Sessions for every locally-known round, fetched in ONE Rust call that +/// holds a single pool connection (per-round loads would need one connection +/// per round and stall the pool once the page lists many rounds). + +final class VotingSessionsAllProvider extends $FunctionalProvider< + AsyncValue>, + Map, + FutureOr>> + with + $FutureModifier>, + $FutureProvider> { + /// Sessions for every locally-known round, fetched in ONE Rust call that + /// holds a single pool connection (per-round loads would need one connection + /// per round and stall the pool once the page lists many rounds). + const VotingSessionsAllProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'votingSessionsAllProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$votingSessionsAllHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return votingSessionsAll(ref); + } +} + +String _$votingSessionsAllHash() => r'f5e91aa2e64beabfa71ae55bf357734a59e7e44f'; + +/// Friendly round title from the vote chain round status, falling back to +/// the round id. The chain's `title` field is the only friendly name source +/// (the config and the local DB carry no titles). + +@ProviderFor(votingRoundTitle) +const votingRoundTitleProvider = VotingRoundTitleFamily._(); + +/// Friendly round title from the vote chain round status, falling back to +/// the round id. The chain's `title` field is the only friendly name source +/// (the config and the local DB carry no titles). + +final class VotingRoundTitleProvider + extends $FunctionalProvider, String, FutureOr> + with $FutureModifier, $FutureProvider { + /// Friendly round title from the vote chain round status, falling back to + /// the round id. The chain's `title` field is the only friendly name source + /// (the config and the local DB carry no titles). + const VotingRoundTitleProvider._( + {required VotingRoundTitleFamily super.from, + required ( + String, + String, + ) + super.argument}) + : super( + retry: null, + name: r'votingRoundTitleProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$votingRoundTitleHash(); + + @override + String toString() { + return r'votingRoundTitleProvider' + '' + '$argument'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as ( + String, + String, + ); + return votingRoundTitle( + ref, + argument.$1, + argument.$2, + ); + } + + @override + bool operator ==(Object other) { + return other is VotingRoundTitleProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$votingRoundTitleHash() => r'2331b63b18a1e939e9bc168a386eae6a081e0edf'; + +/// Friendly round title from the vote chain round status, falling back to +/// the round id. The chain's `title` field is the only friendly name source +/// (the config and the local DB carry no titles). + +final class VotingRoundTitleFamily extends $Family + with + $FunctionalFamilyOverride< + FutureOr, + ( + String, + String, + )> { + const VotingRoundTitleFamily._() + : super( + retry: null, + name: r'votingRoundTitleProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Friendly round title from the vote chain round status, falling back to + /// the round id. The chain's `title` field is the only friendly name source + /// (the config and the local DB carry no titles). + + VotingRoundTitleProvider call( + String roundId, + String chainUrl, + ) => + VotingRoundTitleProvider._(argument: ( + roundId, + chainUrl, + ), from: this); + + @override + String toString() => r'votingRoundTitleProvider'; +} + +/// Normalized chain round status ("active" / "tallying" / "closed") from the +/// vote chain round status, or null when unresolved (non-2xx or absent). +/// Mirrors vizor's `votingPollListStatus`: numeric 1/2/3 plus lenient string +/// forms. Only "tallying"/"closed" rounds have a published tally. + +@ProviderFor(votingRoundStatus) +const votingRoundStatusProvider = VotingRoundStatusFamily._(); + +/// Normalized chain round status ("active" / "tallying" / "closed") from the +/// vote chain round status, or null when unresolved (non-2xx or absent). +/// Mirrors vizor's `votingPollListStatus`: numeric 1/2/3 plus lenient string +/// forms. Only "tallying"/"closed" rounds have a published tally. + +final class VotingRoundStatusProvider + extends $FunctionalProvider, String?, FutureOr> + with $FutureModifier, $FutureProvider { + /// Normalized chain round status ("active" / "tallying" / "closed") from the + /// vote chain round status, or null when unresolved (non-2xx or absent). + /// Mirrors vizor's `votingPollListStatus`: numeric 1/2/3 plus lenient string + /// forms. Only "tallying"/"closed" rounds have a published tally. + const VotingRoundStatusProvider._( + {required VotingRoundStatusFamily super.from, + required ( + String, + String, + ) + super.argument}) + : super( + retry: null, + name: r'votingRoundStatusProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$votingRoundStatusHash(); + + @override + String toString() { + return r'votingRoundStatusProvider' + '' + '$argument'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as ( + String, + String, + ); + return votingRoundStatus( + ref, + argument.$1, + argument.$2, + ); + } + + @override + bool operator ==(Object other) { + return other is VotingRoundStatusProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$votingRoundStatusHash() => r'4c74a550b6b3e70e3b66eccd55b4dc5ce240dac0'; + +/// Normalized chain round status ("active" / "tallying" / "closed") from the +/// vote chain round status, or null when unresolved (non-2xx or absent). +/// Mirrors vizor's `votingPollListStatus`: numeric 1/2/3 plus lenient string +/// forms. Only "tallying"/"closed" rounds have a published tally. + +final class VotingRoundStatusFamily extends $Family + with + $FunctionalFamilyOverride< + FutureOr, + ( + String, + String, + )> { + const VotingRoundStatusFamily._() + : super( + retry: null, + name: r'votingRoundStatusProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Normalized chain round status ("active" / "tallying" / "closed") from the + /// vote chain round status, or null when unresolved (non-2xx or absent). + /// Mirrors vizor's `votingPollListStatus`: numeric 1/2/3 plus lenient string + /// forms. Only "tallying"/"closed" rounds have a published tally. + + VotingRoundStatusProvider call( + String roundId, + String chainUrl, + ) => + VotingRoundStatusProvider._(argument: ( + roundId, + chainUrl, + ), from: this); + + @override + String toString() => r'votingRoundStatusProvider'; +} + +/// Parsed proposals (id, title, option id → label) for a round, from the +/// chain round status. Empty when the fetch fails. + +@ProviderFor(votingRoundProposals) +const votingRoundProposalsProvider = VotingRoundProposalsFamily._(); + +/// Parsed proposals (id, title, option id → label) for a round, from the +/// chain round status. Empty when the fetch fails. + +final class VotingRoundProposalsProvider extends $FunctionalProvider< + AsyncValue>, + List, + FutureOr>> + with + $FutureModifier>, + $FutureProvider> { + /// Parsed proposals (id, title, option id → label) for a round, from the + /// chain round status. Empty when the fetch fails. + const VotingRoundProposalsProvider._( + {required VotingRoundProposalsFamily super.from, + required ( + String, + String, + ) + super.argument}) + : super( + retry: null, + name: r'votingRoundProposalsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$votingRoundProposalsHash(); + + @override + String toString() { + return r'votingRoundProposalsProvider' + '' + '$argument'; + } + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + final argument = this.argument as ( + String, + String, + ); + return votingRoundProposals( + ref, + argument.$1, + argument.$2, + ); + } + + @override + bool operator ==(Object other) { + return other is VotingRoundProposalsProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$votingRoundProposalsHash() => + r'f8e7d46668f5f221a85e0eb62b9c7ec2462a5a6e'; + +/// Parsed proposals (id, title, option id → label) for a round, from the +/// chain round status. Empty when the fetch fails. + +final class VotingRoundProposalsFamily extends $Family + with + $FunctionalFamilyOverride< + FutureOr>, + ( + String, + String, + )> { + const VotingRoundProposalsFamily._() + : super( + retry: null, + name: r'votingRoundProposalsProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Parsed proposals (id, title, option id → label) for a round, from the + /// chain round status. Empty when the fetch fails. + + VotingRoundProposalsProvider call( + String roundId, + String chainUrl, + ) => + VotingRoundProposalsProvider._(argument: ( + roundId, + chainUrl, + ), from: this); + + @override + String toString() => r'votingRoundProposalsProvider'; +} + /// Resolved and authenticated voting config for the configured source URL. -/// Resolves fresh on build; on failure falls back to the last cached config. +/// `build()` returns the last cached resolved config without touching the +/// network (so merely reading the provider never triggers a fetch); call +/// `resolve()` to fetch fresh, falling back to cached on failure. @ProviderFor(VotingConfigNotifier) const votingConfigProvider = VotingConfigNotifierProvider._(); /// Resolved and authenticated voting config for the configured source URL. -/// Resolves fresh on build; on failure falls back to the last cached config. +/// `build()` returns the last cached resolved config without touching the +/// network (so merely reading the provider never triggers a fetch); call +/// `resolve()` to fetch fresh, falling back to cached on failure. final class VotingConfigNotifierProvider extends $AsyncNotifierProvider { /// Resolved and authenticated voting config for the configured source URL. - /// Resolves fresh on build; on failure falls back to the last cached config. + /// `build()` returns the last cached resolved config without touching the + /// network (so merely reading the provider never triggers a fetch); call + /// `resolve()` to fetch fresh, falling back to cached on failure. const VotingConfigNotifierProvider._() : super( from: null, @@ -1595,10 +1989,12 @@ final class VotingConfigNotifierProvider } String _$votingConfigNotifierHash() => - r'ed064685d2a96f9c26b4e3e40636d03d32287caf'; + r'bf23634dc2f92cf3ccff2282522f6629e7dc81db'; /// Resolved and authenticated voting config for the configured source URL. -/// Resolves fresh on build; on failure falls back to the last cached config. +/// `build()` returns the last cached resolved config without touching the +/// network (so merely reading the provider never triggers a fetch); call +/// `resolve()` to fetch fresh, falling back to cached on failure. abstract class _$VotingConfigNotifier extends $AsyncNotifier { FutureOr build(); @@ -1740,7 +2136,7 @@ final class VotingSubmissionJobProvider } String _$votingSubmissionJobHash() => - r'5dfea23a8350a2f2194fe16b725caffd2a1f33a6'; + r'f959d1379c1c2fd71f730fcf36c32f2b80bd35dd'; /// Delegation execution job for one round. Runs the serialized chain: /// prepare (or resume) → setup → build submission (progress stream) → @@ -1806,3 +2202,85 @@ abstract class _$VotingSubmissionJob element.handleValue(ref, created); } } + +/// Session-independent helper-share tracking for one round. +/// +/// Armed by the submission job, the voting polls page, and the post-wallet- +/// open hook, so a client restart does not strand share delivery while the +/// round window is open. Each tick submits unrecorded share payloads +/// (a helper outage during the foreground run leaves them unrecorded), polls +/// helper status for sent shares, and resubmits overdue ones — best-effort, +/// with the next tick retrying after any failure. + +@ProviderFor(VotingShareTracker) +const votingShareTrackerProvider = VotingShareTrackerProvider._(); + +/// Session-independent helper-share tracking for one round. +/// +/// Armed by the submission job, the voting polls page, and the post-wallet- +/// open hook, so a client restart does not strand share delivery while the +/// round window is open. Each tick submits unrecorded share payloads +/// (a helper outage during the foreground run leaves them unrecorded), polls +/// helper status for sent shares, and resubmits overdue ones — best-effort, +/// with the next tick retrying after any failure. +final class VotingShareTrackerProvider + extends $NotifierProvider { + /// Session-independent helper-share tracking for one round. + /// + /// Armed by the submission job, the voting polls page, and the post-wallet- + /// open hook, so a client restart does not strand share delivery while the + /// round window is open. Each tick submits unrecorded share payloads + /// (a helper outage during the foreground run leaves them unrecorded), polls + /// helper status for sent shares, and resubmits overdue ones — best-effort, + /// with the next tick retrying after any failure. + const VotingShareTrackerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'votingShareTrackerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$votingShareTrackerHash(); + + @$internal + @override + VotingShareTracker create() => VotingShareTracker(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$votingShareTrackerHash() => + r'54be86dcd23224ea6f76eebc469ef97181ca15c1'; + +/// Session-independent helper-share tracking for one round. +/// +/// Armed by the submission job, the voting polls page, and the post-wallet- +/// open hook, so a client restart does not strand share delivery while the +/// round window is open. Each tick submits unrecorded share payloads +/// (a helper outage during the foreground run leaves them unrecorded), polls +/// helper status for sent shares, and resubmits overdue ones — best-effort, +/// with the next tick retrying after any failure. + +abstract class _$VotingShareTracker extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = ref.element as $ClassProviderElement< + AnyNotifier, bool, Object?, Object?>; + element.handleValue(ref, created); + } +} diff --git a/lib/utils.dart b/lib/utils.dart index 65602367e..1a4ad4bf8 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -45,6 +45,14 @@ final invertSeparator = NumberFormat.decimalPattern(locale).symbols.DECIMAL_SEP final int zatsPerZec = 100000000; +/// Formats raw zatoshi voting power as e.g. `12.5 ZEC` (up to 4 decimals). +String formatVotingPower(BigInt zatoshi) { + final zec = zatoshi.toDouble() / zatsPerZec; + var s = zec.toStringAsFixed(4); + s = s.replaceFirst(RegExp(r'\.?0+$'), ''); + return "$s ZEC"; +} + /// Format a fiat amount with its currency code. /// Uses the locale-aware number formatter followed by the uppercased currency code. String formatFiat(double amount, String currency) { @@ -58,13 +66,13 @@ String doubleToString(double v, {required int decimals}) { } String zatToString(BigInt zat) { - final z = Fixed.fromBigInt(zat, scale: 8); + final z = Fixed.fromBigInt(zat, decimalDigits: 8); final s = zatFormatter.format(z.toDecimal()); return s; } String zatToShortString(BigInt zat) { - final z = Fixed.fromBigInt(zat, scale: 8); + final z = Fixed.fromBigInt(zat, decimalDigits: 8); final s = zatShortFormatter.format(z.toDecimal()); return s; } @@ -106,7 +114,7 @@ Widget zatToText(BigInt zat, {String prefix = "", TextStyle? style, Function()? Fixed stringToDecimal(String s, {int? scale}) { try { - return Fixed.parse(s, scale: scale, invertSeparator: invertSeparator); + return Fixed.parse(s, decimalDigits: scale, invertSeparator: invertSeparator); } on RangeError { throw FormatException('Invalid decimal: $s'); } @@ -114,7 +122,7 @@ Fixed stringToDecimal(String s, {int? scale}) { BigInt stringToZat(String s) { try { - final z = Fixed.parse(s, scale: 8, invertSeparator: invertSeparator); + final z = Fixed.parse(s, decimalDigits: 8, invertSeparator: invertSeparator); return z.minorUnits; } on RangeError { throw FormatException('Invalid amount: $s'); diff --git a/pubspec.lock b/pubspec.lock index c74e0035a..5a7fbd589 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -429,10 +429,10 @@ packages: dependency: "direct main" description: name: fixed - sha256: "9b67adeef364271501738a7ab452f45f3207b69a7d8d5692eaa22d038757b035" + sha256: d1acef34c8ef195b50228e3b7ec80c24f843ab1bfb1636041b922a62e10123aa url: "https://pub.dev" source: hosted - version: "5.3.4" + version: "6.2.0" fixnum: dependency: transitive description: @@ -882,10 +882,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -1002,10 +1002,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1026,10 +1026,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -1486,26 +1486,26 @@ packages: dependency: transitive description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" toastification: dependency: "direct main" description: @@ -1630,10 +1630,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4bc532416..d9a32f233 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: "Zkool" # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 6.27.0 # x-release-please-version +version: 6.28.0-rc.10 # x-release-please-version environment: sdk: ^3.6.1 @@ -44,7 +44,7 @@ dependencies: flutter_svg: ^2.0.10+1 - fixed: ^5.3.3 + fixed: ^6.2.0 decimal: ^3.2.1 intl: ^0.20.2 diff --git a/release-please-config.json b/release-please-config.json index 64e021bb6..a056d2f03 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,6 +4,9 @@ "release-type": "simple", "component": "zkool", "include-component-in-tag": true, + "prerelease": true, + "versioning": "prerelease", + "prerelease-type": "rc", "changelog-path": "CHANGELOG.md", "extra-files": [ "version.txt", diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..e627d2d48 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,8 @@ +# rustc 1.88 (and a few releases around it) fails to compile libcrux-psq +# 0.0.8 — a transitive nym dependency — with E0716 ("temporary value dropped +# while borrowed"), a known temporary-lifetime regression. 1.95.0 builds the +# whole workspace (incl. flutter_rust_bridge codegen) cleanly. Keep this +# pinned until the nym/libcrux pins move to a version that compiles on newer +# stable releases. +[toolchain] +channel = "1.95.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e42c70616..d969b4b1d 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -13,15 +13,17 @@ required-features = ["graphql"] [dependencies] zcash-trees = { git = "https://github.com/hhanh00/zcash-trees.git", rev = "1c820645e9116bbdfed5719ba8ff1d89b9be6cb1" } -zcash_voting = { git = "https://github.com/hhanh00/zcash_voting.git", rev = "fd4362b097579782565de553b7b8c5a612cda1e2", features = ["zsa-orchard"] } +zcash_voting = { git = "https://github.com/hhanh00/zcash_voting.git", rev = "fccb06451cbc5adde7c274abac08d9e91281e2a4", features = ["zsa-orchard"] } flutter_rust_bridge = { version = "=2.12.0", optional = true } anyhow = "1.0.97" bip39 = "2.1.0" nonempty = "0.11" env_logger = "0.11" +ff = "0.13" hex = "0.4" log = "0.4" +pasta_curves = "0.5" rand = "0.6" rand_core = "0.6" @@ -150,10 +152,15 @@ unexpected_cfgs = {level = "warn", check-cfg = ['cfg(frb_expand)']} [dev-dependencies] chrono = "0.4" rand = "0.6" +# Recovery-reconciliation tests exercise the voting fork's public prelude and +# the in-memory commitment-tree server. Same git source as the zcash_voting +# dep so the workspace [patch] override applies to it too. +vote-commitment-tree = { git = "https://github.com/hhanh00/zcash_voting.git", rev = "fccb06451cbc5adde7c274abac08d9e91281e2a4" } [features] default = ["flutter"] flutter = ["flutter_rust_bridge"] +bundled-sapling-params = ["zcash_proofs/bundled-prover"] graphql = ["juniper", "juniper_warp", "juniper_graphql_ws", "dataloader", "warp", "jsonwebtoken", "bigdecimal", "chrono", "figment", "clap"] ledger = ["hidapi", "ledger-transport"] zemu = ["ledger", "ledger-transport-zemu"] diff --git a/rust/examples/vk_probe.rs b/rust/examples/vk_probe.rs new file mode 100644 index 000000000..587bacd67 --- /dev/null +++ b/rust/examples/vk_probe.rs @@ -0,0 +1,13 @@ +//! Prints the delegation circuit fingerprint and deterministic proof +//! fingerprints under the APP's patched deps. Compare with the same probes +//! run from the fork workspace (crates.io deps). +fn main() { + println!( + "delegation circuit fingerprint: {}", + zcash_voting::zkp1::delegation_circuit_fingerprint() + ); + let (len, proof_hex, pi_hex) = zcash_voting::zkp1::delegation_proof_probe(); + println!("delegation proof size: {len} bytes"); + println!("delegation proof sha256[..16]: {proof_hex}"); + println!("public inputs sha256[..8]: {pi_hex}"); +} diff --git a/rust/src/account.rs b/rust/src/account.rs index 24de7bec7..327e36948 100644 --- a/rust/src/account.rs +++ b/rust/src/account.rs @@ -680,25 +680,31 @@ pub async fn get_account_full_address( .await?; let saddress = sqlx::query( - "SELECT sa.xvk, sa.address FROM sapling_accounts sa + "SELECT sa.xvk, sa.address, a.dindex FROM sapling_accounts sa JOIN accounts a ON sa.account = a.id_account AND sa.account = ?", ) .bind(account) - .map(|row: SqliteRow| { + .map(|row: SqliteRow| -> Result { let xvk: Vec = row.get(0); let address: String = row.get(1); - let fvk = DiversifiableFullViewingKey::from_bytes(&xvk.try_into().unwrap()).unwrap(); + let _dindex: u32 = row.get(2); + let fvk = DiversifiableFullViewingKey::from_bytes(&xvk.try_into().map_err(|_| { + anyhow!("invalid sapling xvk length for account {account}") + })?) + .ok_or_else(|| anyhow!("invalid sapling xvk for account {account}"))?; if scope == 1 && hw == 0 { // we do not need to derive a diversified change address // since they are not exposed to the user let (_, pa) = fvk.change_address(); - pa + Ok(pa) } else { - PaymentAddress::decode(network, &address).unwrap() + PaymentAddress::decode(network, &address) + .map_err(|e| anyhow!("invalid stored sapling address for account {account}: {e}")) } }) .fetch_optional(&mut *connection) - .await?; + .await? + .transpose()?; let oaddress = sqlx::query( "SELECT a.dindex, oa.xvk FROM orchard_accounts oa @@ -1351,7 +1357,7 @@ pub async fn has_pool(connection: &mut SqliteConnection, account: u32, pool: u8) Ok(has_pool) } -fn derive_sapling_address( +pub(crate) fn derive_sapling_address( network: &Network, sxvk: &DiversifiableFullViewingKey, dindex: u32, diff --git a/rust/src/api/account.rs b/rust/src/api/account.rs index c5990a8e4..bb87032f0 100644 --- a/rust/src/api/account.rs +++ b/rust/src/api/account.rs @@ -665,7 +665,7 @@ pub async fn import_account(passphrase: &str, data: &[u8], c: &Coin) -> Result<( let mut connection = c.get_connection().await?; let decrypted = decrypt(passphrase, data)?; - crate::io::import_account(&mut connection, &decrypted).await?; + crate::io::import_account(&c.network(), &mut connection, &decrypted).await?; Ok(()) } diff --git a/rust/src/api/coin.rs b/rust/src/api/coin.rs index 91a2e0c3b..9b61275f8 100644 --- a/rust/src/api/coin.rs +++ b/rust/src/api/coin.rs @@ -141,7 +141,18 @@ impl Coin { pub(crate) async fn get_connection(&self) -> Result> { let pool = self.get_pool()?; - pool.acquire().await.anyhow() + let start = std::time::Instant::now(); + let result = pool.acquire().await.anyhow(); + let elapsed = start.elapsed(); + if elapsed > std::time::Duration::from_secs(2) { + tracing::warn!( + "slow pool acquire took {:?} (size={}, idle={})", + elapsed, + pool.size(), + pool.num_idle() + ); + } + result } #[cfg_attr(feature = "flutter", frb)] @@ -170,6 +181,12 @@ impl Coin { Ok(Coin { proxy, ..self }) } + /// True when traffic to the server goes through the Nym mixnet, either + /// via a mixnet-native nym:// endpoint or the Nym transport. + pub(crate) fn is_mixnet(&self) -> bool { + crate::net::nym_service::parse_nym_url(&self.url).is_some() || self.transport == 2 + } + pub(crate) async fn client(&self) -> Result { // Mixnet-native endpoint (nym:// URL, a nym-rpc service): bypasses // the transport enum entirely — the mixnet IS the transport. diff --git a/rust/src/api/sapling.rs b/rust/src/api/sapling.rs index 629b34fa2..98b46fefb 100644 --- a/rust/src/api/sapling.rs +++ b/rust/src/api/sapling.rs @@ -1,20 +1,30 @@ use std::path::PathBuf; use std::sync::OnceLock; -use anyhow::{anyhow, Context, Result}; +use anyhow::Result; +#[cfg(not(feature = "bundled-sapling-params"))] +use anyhow::{anyhow, Context}; #[cfg(feature = "flutter")] use flutter_rust_bridge::frb; // Sapling parameter constants — must match those in zcash_proofs. +// Only needed when the parameters are loaded from disk (not bundled). +#[cfg(not(feature = "bundled-sapling-params"))] const SAPLING_SPEND_NAME: &str = "sapling-spend.params"; +#[cfg(not(feature = "bundled-sapling-params"))] const SAPLING_OUTPUT_NAME: &str = "sapling-output.params"; +#[cfg(not(feature = "bundled-sapling-params"))] const SAPLING_SPEND_HASH: &str = "8270785a1a0d0bc77196f000ee6d221c9c9894f55307bd9357c3f0105d31ca63991ab91324160d8f53e2bbd3c2633a6eb8bdf5205d822e7f3f73edac51b2b70c"; +#[cfg(not(feature = "bundled-sapling-params"))] const SAPLING_OUTPUT_HASH: &str = "657e3d38dbb5cb5e7dd2970e8b03d69b4787dd907285b5a7f0790dcc8072f60bf593b32cc2d1c030e00ff5ae64bf84c5c3beb84ddc841d48264b4a171744d028"; +#[cfg(not(feature = "bundled-sapling-params"))] const SAPLING_SPEND_BYTES: u64 = 47_958_396; +#[cfg(not(feature = "bundled-sapling-params"))] const SAPLING_OUTPUT_BYTES: u64 = 3_592_860; +#[cfg(not(feature = "bundled-sapling-params"))] const DOWNLOAD_URL: &str = "https://download.z.cash/downloads"; /// Custom Sapling parameters directory, set on platforms where @@ -34,6 +44,7 @@ pub(crate) fn set_sapling_params_dir(dir: PathBuf) { /// /// Returns the custom directory if set (via `set_sapling_params_dir`), /// otherwise falls back to `zcash_proofs::default_params_folder()`. +#[cfg(not(feature = "bundled-sapling-params"))] pub(crate) fn resolve_params_dir() -> Option { SAFLING_PARAMS_DIR .get() @@ -47,54 +58,74 @@ pub struct SaplingParamsStatus { pub downloaded: bool, } -/// Check whether Sapling parameters are already on disk. +/// Check whether Sapling parameters are available. +/// +/// With `bundled-sapling-params` they are compiled into the binary and always +/// considered available. Otherwise checks whether they are on disk. #[cfg_attr(feature = "flutter", frb(sync))] pub fn check_sapling_params() -> SaplingParamsStatus { - let params_dir = resolve_params_dir(); - let downloaded = params_dir - .map(|dir| dir.join(SAPLING_SPEND_NAME).exists() && dir.join(SAPLING_OUTPUT_NAME).exists()) - .unwrap_or(false); - SaplingParamsStatus { downloaded } + #[cfg(feature = "bundled-sapling-params")] + { + return SaplingParamsStatus { downloaded: true }; + } + #[cfg(not(feature = "bundled-sapling-params"))] + { + let params_dir = resolve_params_dir(); + let downloaded = params_dir + .map(|dir| dir.join(SAPLING_SPEND_NAME).exists() && dir.join(SAPLING_OUTPUT_NAME).exists()) + .unwrap_or(false); + SaplingParamsStatus { downloaded } + } } /// Download Sapling parameters from the z.cash download server. /// /// Verifies file size and Blake2b hash upon download. /// Safe to call even if they are already downloaded (no-op if valid). +/// With `bundled-sapling-params` the parameters are compiled into the binary +/// and this is a no-op. #[cfg_attr(feature = "flutter", frb)] pub async fn download_sapling_params() -> Result<()> { - let params_dir = - resolve_params_dir().context("Could not resolve Sapling parameters directory")?; - - // Ensure the params directory exists. - std::fs::create_dir_all(¶ms_dir) - .with_context(|| format!("Failed to create params directory: {:?}", params_dir))?; - - download_and_verify( - ¶ms_dir, - SAPLING_SPEND_NAME, - SAPLING_SPEND_HASH, - SAPLING_SPEND_BYTES, - ) - .await - .context("Failed to download/verify sapling-spend.params")?; - - download_and_verify( - ¶ms_dir, - SAPLING_OUTPUT_NAME, - SAPLING_OUTPUT_HASH, - SAPLING_OUTPUT_BYTES, - ) - .await - .context("Failed to download/verify sapling-output.params")?; - - Ok(()) + #[cfg(feature = "bundled-sapling-params")] + { + Ok(()) + } + #[cfg(not(feature = "bundled-sapling-params"))] + { + let params_dir = + resolve_params_dir().context("Could not resolve Sapling parameters directory")?; + + // Ensure the params directory exists. + std::fs::create_dir_all(¶ms_dir) + .with_context(|| format!("Failed to create params directory: {:?}", params_dir))?; + + download_and_verify( + ¶ms_dir, + SAPLING_SPEND_NAME, + SAPLING_SPEND_HASH, + SAPLING_SPEND_BYTES, + ) + .await + .context("Failed to download/verify sapling-spend.params")?; + + download_and_verify( + ¶ms_dir, + SAPLING_OUTPUT_NAME, + SAPLING_OUTPUT_HASH, + SAPLING_OUTPUT_BYTES, + ) + .await + .context("Failed to download/verify sapling-output.params")?; + + Ok(()) + } } /// Download a single parameter file from `download.z.cash`, verify its size /// and Blake2b hash, then save it to the given directory. /// /// Skips download if an already-validated file exists at the target location. +#[cfg(not(feature = "bundled-sapling-params"))] async fn download_and_verify( dir: &PathBuf, name: &str, diff --git a/rust/src/api/voting.rs b/rust/src/api/voting.rs index 02ae2a058..ff0584604 100644 --- a/rust/src/api/voting.rs +++ b/rust/src/api/voting.rs @@ -5,6 +5,8 @@ //! transitions follow the plan: prepare → setup → sign/prove/submit → confirm, //! then van witness → commit → payloads → record execution → confirm. +use std::sync::Arc; + use anyhow::{anyhow, Result}; use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; @@ -444,11 +446,12 @@ async fn prepare_bundle( let snapshot_height = u32::try_from(round_params.snapshot_height) .map_err(|_| anyhow!("snapshot height {} does not fit u32", round_params.snapshot_height))?; - let mut connection = c.get_connection().await?; let mut client = c.client().await?; - + // The lwd tree-state fetch can take a while (bounded retries); don't hold + // a pool connection across it. let lwd = voting::gather_lwd_inputs(lightwalletd_url, network, &round_params, round_name).await?; + let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; let inputs = voting::load_round_inputs( wallet_network, @@ -497,11 +500,17 @@ pub async fn delegation_setup( bundle_index: u32, c: &Coin, ) -> Result { - let mut connection = c.get_connection().await?; - let wallet_id = voting::voting_wallet_id(&mut connection, c.account).await?; + let wallet_id = { + let mut connection = c.get_connection().await?; + voting::voting_wallet_id(&mut connection, c.account).await? + }; let prepared = voting::load_prepared_bundle(&wallet_id, round_id, bundle_index)?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; + let db = { + let mut connection = c.get_connection().await?; + voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await? + }; + // setup builds the PCZT (long-running); no connection is held across it. let setup = prepared.setup(&db, &NoopProgressReporter).await?; Ok(setup.into()) } @@ -518,11 +527,15 @@ pub async fn delegation_sign_and_submit( pir_server_url: &str, c: &Coin, ) -> Result { - let mut connection = c.get_connection().await?; - let wallet_id = voting::voting_wallet_id(&mut connection, c.account).await?; + let (wallet_id, seed) = { + let mut connection = c.get_connection().await?; + let wallet_id = voting::voting_wallet_id(&mut connection, c.account).await?; + let seed = voting::account_seed(&mut connection, c.account).await?; + (wallet_id, seed) + }; let prepared = voting::load_prepared_bundle(&wallet_id, round_id, bundle_index)?; - let seed = voting::account_seed(&mut connection, c.account).await?; + // PIR proving runs for a while; no connection is held across it. let (submission, _wire_json) = voting::prove_and_submit_delegation( c.get_pool()?, &wallet_id, @@ -581,59 +594,98 @@ pub async fn delegation_build_submission( let account = c.account; let round_id = round_id.to_string(); let pir_server_url = pir_server_url.to_string(); - let mut connection = c.get_connection().await?; - let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let pir_server_url = if pir_server_url.is_empty() { - crate::db::get_prop( - &mut connection, - &format!("voting_round_pir_url:{round_id}"), - ) - .await? - .ok_or_else(|| { - anyhow!("no saved PIR server URL for round {round_id}; pass pir_server_url once") - })? - } else { - crate::db::put_prop( - &mut connection, - &format!("voting_round_pir_url:{round_id}"), - &pir_server_url, - ) - .await?; - pir_server_url - }; - let pir_layout = match pir_layout { - Some(layout) => { - voting::save_pir_layout(&mut connection, &round_id, &layout.to_fork()).await?; - layout - } - None => voting::load_pir_layout(&mut connection, &round_id) + let (wallet_id, pir_server_url, pir_layout, seed) = { + let mut connection = c.get_connection().await?; + let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; + let pir_server_url = if pir_server_url.is_empty() { + crate::db::get_prop( + &mut connection, + &format!("voting_round_pir_url:{round_id}"), + ) .await? - .map(Into::into) .ok_or_else(|| { - anyhow!("no saved PIR layout for round {round_id}; pass pir_layout once") - })?, + anyhow!("no saved PIR server URL for round {round_id}; pass pir_server_url once") + })? + } else { + crate::db::put_prop( + &mut connection, + &format!("voting_round_pir_url:{round_id}"), + &pir_server_url, + ) + .await?; + pir_server_url + }; + let pir_layout = match pir_layout { + Some(layout) => { + voting::save_pir_layout(&mut connection, &round_id, &layout.to_fork()).await?; + layout + } + None => voting::load_pir_layout(&mut connection, &round_id) + .await? + .map(Into::into) + .ok_or_else(|| { + anyhow!("no saved PIR layout for round {round_id}; pass pir_layout once") + })?, + }; + let seed = voting::account_seed(&mut connection, account).await?; + // Release the connection before setup + signing + PIR proving, which + // run for a while; don't hold a pool slot hostage during the long + // phase (a later internal acquire would queue behind it). + (wallet_id, pir_server_url, pir_layout, seed) }; let prepared = voting::load_prepared_bundle(&wallet_id, &round_id, bundle_index)?; - let seed = voting::account_seed(&mut connection, account).await?; - let progress = DelegationProgressBridge::new(move |p| { - let _ = sink.add(p.into()); - }); - let (submission, wire_json) = voting::prove_and_submit_delegation_with_progress( - c.get_pool()?, - &wallet_id, - &prepared, - &seed, - pczt_bytes, - pir_layout.to_fork(), - &pir_server_url, - &progress, - ) - .await?; + let progress = Arc::new(DelegationProgressBridge::new({ + let sink_for_progress = sink.clone(); + move |p| { + let _ = sink_for_progress.add(p.into()); + } + })); + let (submission, wire_json) = + match voting::prove_and_submit_delegation_with_progress( + c.get_pool()?, + &wallet_id, + &prepared, + &seed, + pczt_bytes, + pir_layout.to_fork(), + &pir_server_url, + progress.clone(), + ) + .await + { + Ok(v) => v, + Err(e) => { + // The FRB stream binding runs the call with `unawaited` and + // discards the returned future, so a plain `Err` would surface + // as an unhandled exception the app can never catch. Deliver + // the error through the sink (decoded as AnyhowException on + // the Dart stream) and return a benign Ok — the binding + // discards this value anyway. + let _ = sink.add_error(e); + return Ok(VotingDelegationBuild { + submission: VotingDelegationSubmission { + proof: Vec::new(), + rk: Vec::new(), + nf_signed: Vec::new(), + cmx_new: Vec::new(), + gov_comm: Vec::new(), + gov_nullifiers: Vec::new(), + alpha: Vec::new(), + vote_round_id: String::new(), + spend_auth_sig: Vec::new(), + sighash: Vec::new(), + tx1_effects: Vec::new(), + }, + wire_json: String::new(), + }); + } + }; // The FRB boundary drops this return value (StreamSink params take over), // so persist the wire body for `delegation_wire_json` to pick up. This also // makes a crash between proving and broadcasting resumable without // re-proving. + let mut connection = c.get_connection().await?; crate::db::put_prop( &mut connection, &format!("voting_round_delegation_wire:{round_id}:{bundle_index}"), @@ -680,7 +732,7 @@ pub async fn delegation_mark_submitted( let tx_hash = tx_hash.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; db.mark_delegation_submitted(&round_id, bundle_index, &tx_hash) .await?; Ok(()) @@ -697,8 +749,8 @@ pub async fn delegation_tx_hash( let round_id = round_id.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; - Ok(db.get_delegation_tx_hash(&round_id, bundle_index).await?) + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + Ok(db.get_delegation_tx_hash(&mut *connection, &round_id, bundle_index).await?) } // --------------------------------------------------------------------------- @@ -726,12 +778,22 @@ pub async fn voting_set_ballot_intent( }; let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; db.set_ballot_intent(&round_id, proposal_id, decision, num_options) .await?; Ok(()) } +/// Returns the quantized voting weight (zatoshi) for the account's eligible +/// shielded notes at `snapshot_height`, computed with the same canonical +/// bundle planning as the delegation prepare step — but from the local DB +/// only (no witnesses, no tree state). Shown pre-submission as an estimate. +#[cfg_attr(feature = "flutter", frb)] +pub async fn voting_eligible_weight(snapshot_height: u32, c: &Coin) -> Result { + let mut connection = c.get_connection().await?; + voting::eligible_voting_weight(&mut connection, c.account, snapshot_height).await +} + /// Persists the draft ballot for a round (props table, wallet-scoped). #[cfg_attr(feature = "flutter", frb)] pub async fn voting_drafts_save(round_id: &str, drafts_json: &str, c: &Coin) -> Result<()> { @@ -783,10 +845,13 @@ pub async fn voting_commit_with_progress( voting::vote_van_witness(c.get_pool()?, &wallet_id, &round_id, bundle_index, &vote_node_url) .await?; - let stages = VoteCommitStageBridge::new(move |s| { - let _ = sink.add(s.into()); + let stages = VoteCommitStageBridge::new({ + let sink_for_stages = sink.clone(); + move |s| { + let _ = sink_for_stages.add(s.into()); + } }); - let commitments = voting::commit_votes_with_progress( + let commitments = match voting::commit_votes_with_progress( c.get_pool()?, &wallet_id, &round_id, @@ -796,7 +861,20 @@ pub async fn voting_commit_with_progress( &hotkey, &stages, ) - .await?; + .await + { + Ok(v) => v, + Err(e) => { + // Same FRB stream footgun as delegation_build_submission: the + // binding drops the returned future, so deliver the error via the + // sink and return a benign Ok (the value is discarded anyway). + let _ = sink.add_error(e); + return Ok(VotingVoteCommitments { + bundle_index, + commitments: Vec::new(), + }); + } + }; Ok(commitments.into()) } @@ -837,7 +915,7 @@ pub async fn voting_mark_vote_submitted( let tx_hash = tx_hash.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; db.mark_vote_submitted(&round_id, bundle_index, proposal_id, &tx_hash) .await?; Ok(()) @@ -864,7 +942,7 @@ pub async fn voting_share_record( let round_id = round_id.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; zcash_voting::share::record( &db, &round_id, @@ -888,8 +966,8 @@ pub async fn voting_share_unconfirmed( let round_id = round_id.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; - Ok(zcash_voting::share::unconfirmed(&db, &round_id) + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + Ok(zcash_voting::share::unconfirmed(&db, &mut *connection, &round_id) .await? .into_iter() .map(Into::into) @@ -909,7 +987,7 @@ pub async fn voting_share_confirm( let round_id = round_id.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; zcash_voting::share::confirm(&db, &round_id, bundle_index, proposal_id, share_index).await?; Ok(()) } @@ -928,7 +1006,7 @@ pub async fn voting_share_add_servers( let round_id = round_id.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; zcash_voting::share::add_sent_servers( &db, &round_id, @@ -984,7 +1062,7 @@ pub async fn voting_sync_tree( let vote_node_url = vote_node_url.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; Ok(zcash_voting::precompute::sync_vote_tree(&db, &round_id, &vote_node_url).await?) } @@ -992,6 +1070,122 @@ pub async fn voting_sync_tree( /// delay, last-moment flag, and freshly planned submissions (with local /// entropy) for the unconfirmed shares. #[cfg_attr(feature = "flutter", frb)] +/// One share of a confirmed vote pending helper submission. First-pass +/// submission must enumerate from the confirmed votes' recovery bundles — +/// the `voting_share_delegations` rows only exist after a submission +/// records them. +#[cfg_attr(feature = "flutter", frb(dart_metadata = ("freezed")))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VotingShareSubmissionPayload { + pub bundle_index: u32, + pub proposal_id: u32, + pub share_index: u32, + pub vc_tree_position: Option, +} + +/// Enumerates the share payloads of the round's confirmed votes — the +/// first-pass submission source. +#[cfg_attr(feature = "flutter", frb)] +pub async fn voting_share_payloads( + round_id: &str, + c: &Coin, +) -> Result> { + let account = c.account; + let round_id = round_id.to_string(); + let mut connection = c.get_connection().await?; + let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + let snapshot = zcash_voting::recovery::round_snapshot(&db, &mut *connection, &round_id).await?; + let recorded: std::collections::BTreeSet<(u32, u32, u32)> = db + .share_phases(&mut *connection, &round_id) + .await? + .into_iter() + .map(|(b, p, s, _)| (b, p, s)) + .collect(); + let mut payloads = Vec::new(); + for vote in snapshot.votes { + if vote.phase != zcash_voting::phases::VotePhase::Confirmed { + continue; + } + let Some(bundle) = + zcash_voting::vote::recovery_bundle(&db, &round_id, vote.bundle_index, vote.proposal_id) + .await? + else { + continue; + }; + for payload in zcash_voting::share::recover_payloads(&bundle)? { + // Skip shares already recorded — a resume must not re-send them + // (the tracking loop polls the helpers for their confirmations). + if recorded.contains(&( + vote.bundle_index, + vote.proposal_id, + payload.enc_share.share_index, + )) { + continue; + } + payloads.push(VotingShareSubmissionPayload { + bundle_index: vote.bundle_index, + proposal_id: vote.proposal_id, + share_index: payload.enc_share.share_index, + vc_tree_position: vote.vc_tree_position.map(|p| p as u64), + }); + } + } + Ok(payloads) +} + +/// Count-based share submission plans (submitAt + target servers per share), +/// mirroring vizor's `planShareSubmissions`: policy-sized CSPRNG entropy +/// drawn per call, timing from the round's ceremony start / vote end. +#[cfg_attr(feature = "flutter", frb)] +pub async fn voting_share_plans( + share_count: u32, + server_urls: Vec, + now: u64, + vote_end: u64, + ceremony_start: u64, + single_share: bool, + c: &Coin, +) -> Result> { + let buffer = + zcash_voting::share_policy::last_moment_buffer_seconds(ceremony_start, vote_end); + let share_count = share_count as usize; + let required = zcash_voting::share_policy::share_submission_random_bytes_required( + share_count, + server_urls.len(), + now, + vote_end, + buffer, + single_share, + ); + let mut submit_at_random_bytes = vec![0u8; required.submit_at_random_bytes]; + let mut server_random_bytes = vec![0u8; required.server_random_bytes]; + OsRng + .try_fill_bytes(&mut submit_at_random_bytes) + .map_err(|e| anyhow!("failed to draw submit_at entropy: {e}"))?; + OsRng + .try_fill_bytes(&mut server_random_bytes) + .map_err(|e| anyhow!("failed to draw share-server entropy: {e}"))?; + let plans = zcash_voting::share_policy::plan_share_submissions( + share_count, + &server_urls, + now, + vote_end, + buffer, + single_share, + &submit_at_random_bytes, + &server_random_bytes, + )?; + Ok(plans + .into_iter() + .map(|p| VotingSharePlanItem { + submit_at: p.submit_at, + target_count: p.target_count, + target_servers: p.target_servers, + }) + .collect()) +} + pub async fn voting_share_plan( round_id: &str, now: u64, @@ -1005,8 +1199,8 @@ pub async fn voting_share_plan( let round_id = round_id.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; - let shares = zcash_voting::share::unconfirmed(&db, &round_id).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + let shares = zcash_voting::share::unconfirmed(&db, &mut *connection, &round_id).await?; let policy = ShareTimingPolicy::default(); let summary = zcash_voting::share_policy::summarize_share_tracking( @@ -1324,6 +1518,148 @@ pub async fn voting_confirm( Ok(confirmation.into()) } +// --------------------------------------------------------------------------- +// Chain-evidence recovery +// +// When a submitted tx's hash is unknown (the broadcast response was lost and +// the chain spent the nullifier), the commitment tree still holds the +// evidence: the client's own vote commitment and VAN commitments are known +// from persisted recovery state. These helpers locate the leaves and record +// the confirmation without a tx hash. +// --------------------------------------------------------------------------- + +/// Confirmation evidence recovered from a commitment-tree scan. +#[cfg_attr(feature = "flutter", frb(dart_metadata = ("freezed")))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VotingTreeVoteConfirmation { + pub vc_tree_position: u64, + pub van_leaf_position: Option, +} + +/// Hex-encoded vote commitment leaf value for one committed vote, used to +/// locate the vote's commitment-tree leaf when the tx hash is unknown. +#[cfg_attr(feature = "flutter", frb)] +pub async fn voting_vote_commitment_hex( + round_id: &str, + bundle_index: u32, + proposal_id: u32, + c: &Coin, +) -> Result { + let mut connection = c.get_connection().await?; + let wallet_id = voting::voting_wallet_id(&mut connection, c.account).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + let committed = + zcash_voting::prelude::CommittedVote::recover(&db, round_id, bundle_index, proposal_id) + .await?; + let signed = committed.signed_commitment(&db).await?; + Ok(hex::encode(signed.vote_commitment)) +} + +/// Hex-encoded cast-vote VAN output commitment for one committed vote (the +/// commitment-tree leaf appended immediately before the vote commitment). +#[cfg_attr(feature = "flutter", frb)] +pub async fn voting_vote_van_commitment_hex( + round_id: &str, + bundle_index: u32, + proposal_id: u32, + c: &Coin, +) -> Result { + let mut connection = c.get_connection().await?; + let wallet_id = voting::voting_wallet_id(&mut connection, c.account).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + let committed = + zcash_voting::prelude::CommittedVote::recover(&db, round_id, bundle_index, proposal_id) + .await?; + let signed = committed.signed_commitment(&db).await?; + Ok(hex::encode(signed.vote_authority_note_new)) +} + +/// Hex-encoded delegation VAN commitment (`gov_comm`) for a bundle, or `None` +/// when it was never persisted. Used to locate the delegation's tree leaf. +#[cfg_attr(feature = "flutter", frb)] +pub async fn voting_delegation_van_commitment_hex( + round_id: &str, + bundle_index: u32, + c: &Coin, +) -> Result> { + use ff::PrimeField; + let mut connection = c.get_connection().await?; + let wallet_id = voting::voting_wallet_id(&mut connection, c.account).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + let van = zcash_voting::prelude::delegation_van_commitment(&db, round_id, bundle_index).await?; + Ok(van.map(|value| hex::encode(value.to_repr()))) +} + +/// Scans the round's commitment tree for a leaf matching `target_hex` and +/// returns its global position, or `None` when absent. +#[cfg_attr(feature = "flutter", frb)] +pub async fn voting_tree_find_leaf( + round_id: &str, + node_url: &str, + target_hex: &str, +) -> Result> { + use ff::PrimeField; + let bytes: [u8; 32] = hex::decode(target_hex)? + .try_into() + .map_err(|_| anyhow::anyhow!("vote commitment hex must be 32 bytes"))?; + let target = Option::from(pasta_curves::Fp::from_repr(bytes)) + .ok_or_else(|| anyhow::anyhow!("vote commitment hex is not a canonical field element"))?; + Ok(zcash_voting::prelude::find_leaf_position(node_url, round_id, target).await?) +} + +/// Records a cast-vote confirmation whose evidence came from a +/// commitment-tree scan (no tx hash available). The vote's phase becomes +/// Confirmed so the resume plan proceeds to share submission. +#[cfg_attr(feature = "flutter", frb)] +pub async fn voting_recover_confirm_vote_from_tree( + round_id: &str, + bundle_index: u32, + proposal_id: u32, + vc_tree_position: u64, + van_leaf_position: Option, + c: &Coin, +) -> Result { + let mut connection = c.get_connection().await?; + let wallet_id = voting::voting_wallet_id(&mut connection, c.account).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + let confirmation = zcash_voting::prelude::record_vote_confirmation_from_tree( + &db, + round_id, + bundle_index, + proposal_id, + vc_tree_position, + van_leaf_position, + ) + .await?; + Ok(VotingTreeVoteConfirmation { + vc_tree_position: confirmation.vc_tree_position, + van_leaf_position: confirmation.van_leaf_position, + }) +} + +/// Records a delegation confirmation recovered from a commitment-tree scan +/// (no tx hash available). The bundle's phase becomes Confirmed so voting can +/// proceed. +#[cfg_attr(feature = "flutter", frb)] +pub async fn voting_recover_confirm_delegation_from_tree( + round_id: &str, + bundle_index: u32, + van_leaf_position: u32, + c: &Coin, +) -> Result<()> { + let mut connection = c.get_connection().await?; + let wallet_id = voting::voting_wallet_id(&mut connection, c.account).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + zcash_voting::prelude::record_delegation_confirmation_from_tree( + &db, + round_id, + bundle_index, + van_leaf_position, + ) + .await?; + Ok(()) +} + // --------------------------------------------------------------------------- // Recovery / plan mirrors // --------------------------------------------------------------------------- @@ -1469,6 +1805,17 @@ pub struct VotingBallotIntent { pub choice: Option, } +/// One round's full session state: resume plan, recovery snapshot, and +/// ballot intents — loaded together under a single pool connection. +#[cfg_attr(feature = "flutter", frb(dart_metadata = ("freezed")))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VotingRoundSession { + pub round_id: String, + pub plan: VotingRoundPlan, + pub recovery: VotingRoundRecovery, + pub intents: Vec, +} + /// Delegation proof/signing progress event, one-to-one with the fork's /// `DelegationProgress`. The bookend variants (`SelectingNotes`, /// `SigningPayload`, `PayloadReady`) are emitted by the host wrapper; the @@ -1844,7 +2191,6 @@ fn config_switch_kind_string(kind: zcash_voting::config::ConfigSwitchKind) -> St } zcash_voting::config::ConfigSwitchKind::NewChainOrRound => "new_chain_or_round".to_string(), zcash_voting::config::ConfigSwitchKind::ProtocolChanged => "protocol_changed".to_string(), - _ => "unchanged".to_string(), } } @@ -1902,8 +2248,8 @@ pub async fn voting_rounds(c: &Coin) -> Result> { let account = c.account; let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; - let rounds = db.rounds().await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + let rounds = db.rounds(&mut *connection).await?; Ok(rounds.into_iter().map(Into::into).collect()) } @@ -1916,8 +2262,8 @@ pub async fn voting_plan(round_id: &str, proposal_ids: Vec, c: &Coin) -> Re let round_id = round_id.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; - let plan = zcash_voting::session::resume_plan(&db, &round_id, &proposal_ids).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + let plan = zcash_voting::session::resume_plan(&db, &mut *connection, &round_id, &proposal_ids).await?; Ok(plan.into()) } @@ -1928,8 +2274,8 @@ pub async fn voting_recovery(round_id: &str, c: &Coin) -> Result Result<()> { let round_id = round_id.to_string(); let mut connection = c.get_connection().await?; let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; - let db = voting::open_voting_db(c.get_pool()?, &wallet_id).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; zcash_voting::recovery::clear(&db, &round_id).await?; Ok(()) } +/// Resets process-local vote-tree cache and clears unsigned delegation setup +/// fields for a round (the fork's recovery when a restart after +/// `build_governance_pczt` persisted `pczt_sighash` makes re-setup refuse to +/// overwrite it). Submitted bundles, imported capabilities, and bundles with +/// persisted Keystone signatures are preserved. +#[cfg_attr(feature = "flutter", frb)] +pub async fn voting_reset_session_state(round_id: &str, c: &Coin) -> Result<()> { + let account = c.account; + let round_id = round_id.to_string(); + let mut connection = c.get_connection().await?; + let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + zcash_voting::precompute::reset_voting_session_state(&db, &round_id).await?; + Ok(()) +} + /// Returns the persisted ballot intents for a round, sorted by proposal id. #[cfg_attr(feature = "flutter", frb)] pub async fn voting_ballot_intents(round_id: &str, c: &Coin) -> Result> { @@ -1953,11 +2315,54 @@ pub async fn voting_ballot_intents(round_id: &str, c: &Coin) -> Result, c: &Coin) -> Result> { + let account = c.account; + let mut connection = c.get_connection().await?; + let wallet_id = voting::voting_wallet_id(&mut connection, account).await?; + let db = voting::open_voting_db(c.get_pool()?, &mut *connection, &wallet_id).await?; + let mut sessions = Vec::with_capacity(round_ids.len()); + for round_id in round_ids { + // Draft proposal ids live in wallet props; read them on the same + // connection so the plan sees open proposals (mirrors the Dart + // votingSession._draftProposalIds). + let draft_ids = match crate::db::get_prop( + &mut connection, + &format!("voting_drafts:{round_id}"), + ) + .await? + { + Some(d) if !d.is_empty() => serde_json::from_str::>(&d) + .unwrap_or_default() + .iter() + .filter_map(|x| x.get("proposal_id").and_then(|v| v.as_u64()).map(|n| n as u32)) + .filter(|&id| id > 0) + .collect::>(), + _ => Vec::new(), + }; + let plan = zcash_voting::session::resume_plan(&db, &mut *connection, &round_id, &draft_ids) + .await?; + let recovery = + zcash_voting::recovery::round_snapshot(&db, &mut *connection, &round_id).await?; + let intents = db.ballot_intents(&mut *connection, &round_id).await?; + sessions.push(VotingRoundSession { + round_id, + plan: plan.into(), + recovery: recovery.into(), + intents: intents.into_iter().map(Into::into).collect(), + }); + } + Ok(sessions) +} + // --------------------------------------------------------------------------- // Vote-chain HTTP // --------------------------------------------------------------------------- @@ -1972,6 +2377,7 @@ pub async fn voting_ballot_intents(round_id: &str, c: &Coin) -> Result, } /// Voting traffic honors the external-proxy setting (transport 3) only; it is @@ -1989,8 +2395,8 @@ fn votechain_proxy(c: &Coin) -> String { pub async fn votechain_list_rounds(base_url: &str, c: &Coin) -> Result { let base_url = base_url.to_string(); let proxy = votechain_proxy(c); - let (status_code, body) = crate::net::votechain::list_rounds(&base_url, &proxy).await?; - Ok(VotingChainResponse { status_code, body }) + let (status_code, body, retry_after_secs) = crate::net::votechain::list_rounds(&base_url, &proxy).await?; + Ok(VotingChainResponse { status_code, body, retry_after_secs }) } /// Fetches one round's status (`{ "round": ... }` envelope). @@ -2003,9 +2409,9 @@ pub async fn votechain_round_status( let base_url = base_url.to_string(); let round_id = round_id.to_string(); let proxy = votechain_proxy(c); - let (status_code, body) = + let (status_code, body, retry_after_secs) = crate::net::votechain::round_status(&base_url, &round_id, &proxy).await?; - Ok(VotingChainResponse { status_code, body }) + Ok(VotingChainResponse { status_code, body, retry_after_secs }) } /// Fetches the round tally envelope. @@ -2018,9 +2424,9 @@ pub async fn votechain_round_tally( let base_url = base_url.to_string(); let round_id = round_id.to_string(); let proxy = votechain_proxy(c); - let (status_code, body) = + let (status_code, body, retry_after_secs) = crate::net::votechain::round_tally(&base_url, &round_id, &proxy).await?; - Ok(VotingChainResponse { status_code, body }) + Ok(VotingChainResponse { status_code, body, retry_after_secs }) } /// Broadcasts a delegation transaction to the vote chain. @@ -2033,9 +2439,9 @@ pub async fn votechain_submit_delegation( let base_url = base_url.to_string(); let submission_json = submission_json.to_string(); let proxy = votechain_proxy(c); - let (status_code, body) = + let (status_code, body, retry_after_secs) = crate::net::votechain::submit_delegation(&base_url, &submission_json, &proxy).await?; - Ok(VotingChainResponse { status_code, body }) + Ok(VotingChainResponse { status_code, body, retry_after_secs }) } /// Broadcasts a vote commitment transaction to the vote chain. @@ -2048,9 +2454,9 @@ pub async fn votechain_submit_vote( let base_url = base_url.to_string(); let submission_json = submission_json.to_string(); let proxy = votechain_proxy(c); - let (status_code, body) = + let (status_code, body, retry_after_secs) = crate::net::votechain::submit_vote_commitment(&base_url, &submission_json, &proxy).await?; - Ok(VotingChainResponse { status_code, body }) + Ok(VotingChainResponse { status_code, body, retry_after_secs }) } /// Fetches the on-chain confirmation for a transaction; 404 = not confirmed. @@ -2063,9 +2469,9 @@ pub async fn votechain_tx_confirmation( let base_url = base_url.to_string(); let tx_hash = tx_hash.to_string(); let proxy = votechain_proxy(c); - let (status_code, body) = + let (status_code, body, retry_after_secs) = crate::net::votechain::tx_confirmation(&base_url, &tx_hash, &proxy).await?; - Ok(VotingChainResponse { status_code, body }) + Ok(VotingChainResponse { status_code, body, retry_after_secs }) } /// Posts one encrypted share to a helper server. @@ -2078,9 +2484,9 @@ pub async fn votechain_submit_share( let server_url = server_url.to_string(); let payload_json = payload_json.to_string(); let proxy = votechain_proxy(c); - let (status_code, body) = + let (status_code, body, retry_after_secs) = crate::net::votechain::submit_share(&server_url, &payload_json, &proxy).await?; - Ok(VotingChainResponse { status_code, body }) + Ok(VotingChainResponse { status_code, body, retry_after_secs }) } /// Resends a previously generated share to a helper server (same endpoint as @@ -2094,9 +2500,9 @@ pub async fn votechain_resubmit_share( let server_url = server_url.to_string(); let payload_json = payload_json.to_string(); let proxy = votechain_proxy(c); - let (status_code, body) = + let (status_code, body, retry_after_secs) = crate::net::votechain::submit_share(&server_url, &payload_json, &proxy).await?; - Ok(VotingChainResponse { status_code, body }) + Ok(VotingChainResponse { status_code, body, retry_after_secs }) } /// Checks whether a helper has confirmed a share identified by its nullifier. @@ -2111,7 +2517,1232 @@ pub async fn votechain_share_status( let round_id = round_id.to_string(); let share_id = share_id.to_string(); let proxy = votechain_proxy(c); - let (status_code, body) = + let (status_code, body, retry_after_secs) = crate::net::votechain::share_status(&server_url, &round_id, &share_id, &proxy).await?; - Ok(VotingChainResponse { status_code, body }) + Ok(VotingChainResponse { status_code, body, retry_after_secs }) +} + +#[cfg(test)] +mod tests { + use super::*; + use zcash_voting::config::{ + AuthenticatedRound, ConfigCondition, ConfigConditionKind, ConfigSwitchKind, PirLayout, + ResolvedVotingConfig, ServiceEndpoint, SupportedVersions, + }; + use zcash_voting::phases::{DelegationPhase, SharePhase, VotePhase}; + use zcash_voting::prelude::{ + DelegationConfirmation, DelegationSetup, DelegationSubmission, SharePayload, + SignedVoteCommitments, VanWitness, VoteConfirmation, VoteSubmission, + }; + use zcash_voting::session::{ + CompletedVoteChoice, CompletedVoteDisplay, DelegationRecoveryWork, + DelegationRecoveryWorkKind, DelegationStatus, RoundPlanAction, + }; + use zcash_voting::share_policy::ShareTrackingSummary; + use zcash_voting::vote::{SignedVoteCommitment, VoteCommitStage}; + use zcash_voting::WireEncryptedShare; + + fn assert_round_trips(v: T) + where + T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug, + { + let json = serde_json::to_string(&v).unwrap(); + let back: T = serde_json::from_str(&json).unwrap(); + assert_eq!(back, v); + } + + fn coin(transport: u8, proxy: &str) -> Coin { + Coin { + coin: 0, + account: 0, + db_filepath: String::new(), + url: String::new(), + server_type: 0, + transport, + proxy: proxy.to_string(), + } + } + + #[test] + fn voting_pir_layout_from_and_to_fork_round_trip() { + let fork = PirLayout { + pir_depth: 5, + tier0_layers: 2, + tier1_layers: 3, + poly_len: 2048, + }; + let mirror = VotingPirLayout::from(fork); + assert_eq!( + mirror, + VotingPirLayout { + pir_depth: 5, + tier0_layers: 2, + tier1_layers: 3, + poly_len: 2048, + } + ); + assert_eq!(mirror.to_fork(), fork); + } + + #[test] + fn voting_delegation_setup_from_maps_all_fields() { + let fork = DelegationSetup { + pczt_bytes: vec![1, 2], + pczt_sighash: [3u8; 32], + rk: [4u8; 32], + action_index: 65536, + action_bytes: vec![5], + tx1_effects: vec![6], + }; + assert_eq!( + VotingDelegationSetup::from(fork), + VotingDelegationSetup { + pczt_bytes: vec![1, 2], + pczt_sighash: vec![3u8; 32], + rk: vec![4u8; 32], + action_index: 65536, + action_bytes: vec![5], + tx1_effects: vec![6], + } + ); + } + + #[test] + fn voting_delegation_submission_from_maps_all_fields() { + let fork = DelegationSubmission { + proof: vec![1], + rk: [2u8; 32], + nf_signed: [3u8; 32], + cmx_new: [4u8; 32], + gov_comm: [5u8; 32], + gov_nullifiers: [[6u8; 32], [7u8; 32], [8u8; 32], [9u8; 32], [10u8; 32]], + alpha: [11u8; 32], + vote_round_id: "round-1".to_string(), + spend_auth_sig: [12u8; 64], + sighash: [13u8; 32], + tx1_effects: vec![14], + }; + assert_eq!( + VotingDelegationSubmission::from(fork), + VotingDelegationSubmission { + proof: vec![1], + rk: vec![2u8; 32], + nf_signed: vec![3u8; 32], + cmx_new: vec![4u8; 32], + gov_comm: vec![5u8; 32], + gov_nullifiers: vec![ + vec![6u8; 32], + vec![7u8; 32], + vec![8u8; 32], + vec![9u8; 32], + vec![10u8; 32], + ], + alpha: vec![11u8; 32], + vote_round_id: "round-1".to_string(), + spend_auth_sig: vec![12u8; 64], + sighash: vec![13u8; 32], + tx1_effects: vec![14], + } + ); + } + + #[test] + fn voting_delegation_confirmation_from_maps_all_fields() { + let fork = DelegationConfirmation { + tx_hash: "0xabc".to_string(), + van_leaf_position: 9, + }; + assert_eq!( + VotingDelegationConfirmation::from(fork), + VotingDelegationConfirmation { + tx_hash: "0xabc".to_string(), + van_leaf_position: 9, + } + ); + } + + #[test] + fn voting_vote_confirmation_from_maps_all_fields() { + let fork = VoteConfirmation { + tx_hash: "0xdef".to_string(), + van_leaf_position: 9, + vc_tree_position: 7, + }; + assert_eq!( + VotingVoteConfirmation::from(fork), + VotingVoteConfirmation { + tx_hash: "0xdef".to_string(), + van_leaf_position: 9, + vc_tree_position: 7, + } + ); + } + + #[test] + fn voting_van_witness_from_maps_all_fields() { + let fork = VanWitness { + auth_path: vec![vec![1], vec![2, 3]], + position: 4, + anchor_height: 5, + }; + assert_eq!( + VotingVanWitness::from(fork), + VotingVanWitness { + auth_path: vec![vec![1], vec![2, 3]], + position: 4, + anchor_height: 5, + } + ); + } + + #[test] + fn voting_signed_vote_commitments_from_maps_all_fields() { + let fork = SignedVoteCommitments { + bundle_index: 3, + commitments: vec![ + SignedVoteCommitment { + proposal_id: 1, + choice: 2, + vote_round_id: "r".to_string(), + van_nullifier: [3u8; 32], + vote_authority_note_new: [4u8; 32], + vote_commitment: [5u8; 32], + proof: vec![6], + encrypted_shares: vec![], + share_payloads: vec![], + anchor_height: 7, + shares_hash: [8u8; 32], + share_comms: vec![], + r_vpk: [9u8; 32], + vote_auth_sig: [10u8; 64], + commitment_bundle_json: "{}".to_string(), + }, + SignedVoteCommitment { + proposal_id: 11, + choice: 12, + vote_round_id: "r2".to_string(), + van_nullifier: [13u8; 32], + vote_authority_note_new: [14u8; 32], + vote_commitment: [15u8; 32], + proof: vec![16], + encrypted_shares: vec![], + share_payloads: vec![], + anchor_height: 17, + shares_hash: [18u8; 32], + share_comms: vec![], + r_vpk: [19u8; 32], + vote_auth_sig: [20u8; 64], + commitment_bundle_json: "{\"x\":1}".to_string(), + }, + ], + }; + let mirror = VotingVoteCommitments::from(fork); + assert_eq!(mirror.bundle_index, 3); + assert_eq!(mirror.commitments.len(), 2); + assert_eq!( + mirror.commitments[0], + VotingSignedVoteCommitment { + proposal_id: 1, + choice: 2, + vote_round_id: "r".to_string(), + van_nullifier: vec![3u8; 32], + vote_authority_note_new: vec![4u8; 32], + vote_commitment: vec![5u8; 32], + proof: vec![6], + anchor_height: 7, + r_vpk: vec![9u8; 32], + vote_auth_sig: vec![10u8; 64], + commitment_bundle_json: "{}".to_string(), + } + ); + assert_eq!(mirror.commitments[1].commitment_bundle_json, "{\"x\":1}"); + + // Empty edge. + let empty = VotingVoteCommitments::from(SignedVoteCommitments { + bundle_index: 4, + commitments: vec![], + }); + assert!(empty.commitments.is_empty()); + } + + #[test] + fn voting_encrypted_share_from_maps_all_fields() { + let fork = WireEncryptedShare { + c1: vec![1], + c2: vec![2], + share_index: 3, + }; + assert_eq!( + VotingEncryptedShare::from(&fork), + VotingEncryptedShare { + c1: vec![1], + c2: vec![2], + share_index: 3, + } + ); + } + + #[test] + fn voting_share_payload_from_maps_all_fields() { + let fork = SharePayload { + shares_hash: vec![1], + proposal_id: 2, + vote_decision: 3, + enc_share: WireEncryptedShare { + c1: vec![4], + c2: vec![5], + share_index: 6, + }, + tree_position: 7, + all_enc_shares: vec![WireEncryptedShare { + c1: vec![8], + c2: vec![9], + share_index: 10, + }], + share_comms: vec![vec![11]], + primary_blind: vec![12], + }; + assert_eq!( + VotingSharePayload::from(&fork), + VotingSharePayload { + shares_hash: vec![1], + proposal_id: 2, + vote_decision: 3, + enc_share: VotingEncryptedShare { + c1: vec![4], + c2: vec![5], + share_index: 6, + }, + tree_position: 7, + all_enc_shares: vec![VotingEncryptedShare { + c1: vec![8], + c2: vec![9], + share_index: 10, + }], + share_comms: vec![vec![11]], + primary_blind: vec![12], + } + ); + } + + #[test] + fn voting_vote_submission_from_maps_all_fields() { + let fork = VoteSubmission { + vote_round_id: "r".to_string(), + proposal_id: 1, + van_nullifier: [2u8; 32], + vote_authority_note_new: [3u8; 32], + vote_commitment: [4u8; 32], + proof: vec![5], + r_vpk: [6u8; 32], + vote_auth_sig: [7u8; 64], + anchor_height: 8, + }; + assert_eq!( + VotingVoteSubmission::from(fork), + VotingVoteSubmission { + vote_round_id: "r".to_string(), + proposal_id: 1, + van_nullifier: vec![2u8; 32], + vote_authority_note_new: vec![3u8; 32], + vote_commitment: vec![4u8; 32], + proof: vec![5], + r_vpk: vec![6u8; 32], + vote_auth_sig: vec![7u8; 64], + anchor_height: 8, + } + ); + } + + #[test] + fn delegation_progress_maps_every_variant() { + let cases = [ + ( + DelegationProgress::SelectingNotes, + VotingDelegationProgress::SelectingNotes, + ), + ( + DelegationProgress::PcztBuilding, + VotingDelegationProgress::PcztBuilding, + ), + ( + DelegationProgress::PcztBuilt, + VotingDelegationProgress::PcztBuilt, + ), + ( + DelegationProgress::ProofStarting, + VotingDelegationProgress::ProofStarting, + ), + ( + DelegationProgress::ProofProgress(0.5), + VotingDelegationProgress::ProofProgress { progress: 0.5 }, + ), + ( + DelegationProgress::ProofComplete, + VotingDelegationProgress::ProofComplete, + ), + ( + DelegationProgress::SigningPayload, + VotingDelegationProgress::SigningPayload, + ), + ( + DelegationProgress::PayloadReady, + VotingDelegationProgress::PayloadReady, + ), + ]; + for (fork, expected) in cases { + assert_eq!(VotingDelegationProgress::from(fork), expected); + } + } + + #[test] + fn vote_commit_stage_maps_every_variant() { + let cases = [ + ( + VoteCommitStage::ProofStarting { + proposal_id: 1, + bundle_index: 2, + }, + VotingVoteCommitStage::ProofStarting { + proposal_id: 1, + bundle_index: 2, + }, + ), + ( + VoteCommitStage::ProofProgress { + proposal_id: 1, + bundle_index: 2, + progress: 0.25, + }, + VotingVoteCommitStage::ProofProgress { + proposal_id: 1, + bundle_index: 2, + progress: 0.25, + }, + ), + ( + VoteCommitStage::SharePayloadsBuilding { + proposal_id: 1, + bundle_index: 2, + }, + VotingVoteCommitStage::SharePayloadsBuilding { + proposal_id: 1, + bundle_index: 2, + }, + ), + ( + VoteCommitStage::Signing { + proposal_id: 1, + bundle_index: 2, + }, + VotingVoteCommitStage::Signing { + proposal_id: 1, + bundle_index: 2, + }, + ), + ]; + for (fork, expected) in cases { + assert_eq!(VotingVoteCommitStage::from(fork), expected); + } + } + + #[test] + fn round_info_from_maps_all_fields_and_network_string() { + for (network, expected_network) in [ + (VotingNetwork::Mainnet, "mainnet"), + (VotingNetwork::Testnet, "testnet"), + (VotingNetwork::Regtest, "regtest"), + ] { + let fork = ForkRoundInfo { + round_id: "r1".to_string(), + network, + snapshot_height: 100, + hotkey_address: Some("addr".to_string()), + eligible_weight: Some(50_000), + bundle_count: 2, + created_at: 123, + }; + let mirror = VotingRoundInfo::from(fork); + assert_eq!(mirror.round_id, "r1"); + assert_eq!(mirror.network, expected_network); + assert_eq!(mirror.snapshot_height, 100); + assert_eq!(mirror.hotkey_address.as_deref(), Some("addr")); + assert_eq!(mirror.eligible_weight_zatoshi, Some(50_000)); + assert_eq!(mirror.bundle_count, 2); + assert_eq!(mirror.created_at, 123); + } + + let fork = ForkRoundInfo { + round_id: "r2".to_string(), + network: VotingNetwork::Mainnet, + snapshot_height: 0, + hotkey_address: None, + eligible_weight: None, + bundle_count: 0, + created_at: 0, + }; + let mirror = VotingRoundInfo::from(fork); + assert_eq!(mirror.hotkey_address, None); + assert_eq!(mirror.eligible_weight_zatoshi, None); + } + + #[test] + fn next_step_maps_every_variant() { + fn assert_step( + step: NextStep, + kind: &str, + bundle: u32, + proposal: u32, + choice: u32, + share: u32, + ) { + let mirror = VotingNextStep::from(step); + assert_eq!(mirror.kind, kind); + assert_eq!(mirror.bundle_index, bundle); + assert_eq!(mirror.proposal_id, proposal); + assert_eq!(mirror.choice, choice); + assert_eq!(mirror.share_index, share); + } + + assert_step(NextStep::Delegate { bundle_index: 1 }, "delegate", 1, 0, 0, 0); + assert_step( + NextStep::PollDelegation { bundle_index: 2 }, + "poll_delegation", + 2, + 0, + 0, + 0, + ); + assert_step( + NextStep::CastVote { + bundle_index: 3, + proposal_id: 4, + choice: 5, + }, + "cast_vote", + 3, + 4, + 5, + 0, + ); + assert_step( + NextStep::SubmitVote { + bundle_index: 6, + proposal_id: 7, + }, + "submit_vote", + 6, + 7, + 0, + 0, + ); + assert_step( + NextStep::PollVote { + bundle_index: 8, + proposal_id: 9, + }, + "poll_vote", + 8, + 9, + 0, + 0, + ); + assert_step( + NextStep::SubmitShares { + bundle_index: 10, + proposal_id: 11, + share_index: 12, + }, + "submit_shares", + 10, + 11, + 0, + 12, + ); + assert_step( + NextStep::ConfirmShare { + bundle_index: 13, + proposal_id: 14, + share_index: 15, + }, + "confirm_share", + 13, + 14, + 0, + 15, + ); + } + + #[test] + fn round_plan_from_maps_all_fields() { + let fork = ForkRoundPlan { + round_id: "r1".to_string(), + pending_recovery: true, + next_steps: vec![ + NextStep::Delegate { bundle_index: 1 }, + NextStep::PollVote { + bundle_index: 2, + proposal_id: 3, + }, + ], + open_proposals: vec![3, 4], + all_decided: false, + delegation_statuses: vec![DelegationStatus { + bundle_index: 1, + phase: DelegationPhase::Proved, + tx_hash: Some("t1".to_string()), + }], + blocking_recovery: true, + blocking_share_work: false, + hotkey_bound: true, + completed_vote_artifact: true, + completed_for_display: true, + completed_vote_display: Some(CompletedVoteDisplay { + choices: vec![ + CompletedVoteChoice { + proposal_id: 3, + choice: Some(1), + }, + CompletedVoteChoice { + proposal_id: 4, + choice: None, + }, + ], + voted_at: Some(42), + }), + needs_draft_setup: false, + primary_action: RoundPlanAction::Done, + recovered_delegation_work: vec![DelegationRecoveryWork { + kind: DelegationRecoveryWorkKind::PollDelegation, + bundle_index: 1, + phase: DelegationPhase::Submitted, + tx_hash: Some("t1".to_string()), + }], + recovered_vote_work: vec![], + }; + let mirror = VotingRoundPlan::from(fork); + assert_eq!(mirror.round_id, "r1"); + assert!(mirror.pending_recovery); + assert_eq!(mirror.next_steps.len(), 2); + assert_eq!( + mirror.next_steps[0], + VotingNextStep { + kind: "delegate".to_string(), + bundle_index: 1, + proposal_id: 0, + choice: 0, + share_index: 0, + } + ); + assert_eq!(mirror.open_proposals, vec![3, 4]); + assert!(!mirror.all_decided); + assert_eq!( + mirror.delegation_statuses, + vec![VotingDelegationStatus { + bundle_index: 1, + phase: "proved".to_string(), + tx_hash: Some("t1".to_string()), + }] + ); + assert!(mirror.blocking_recovery); + assert!(!mirror.blocking_share_work); + assert!(mirror.hotkey_bound); + assert!(mirror.completed_vote_artifact); + assert!(mirror.completed_for_display); + assert!(!mirror.needs_draft_setup); + assert_eq!(mirror.primary_action, "done"); + let display = mirror.completed_vote_display.unwrap(); + assert_eq!( + display.choices, + vec![ + VotingCompletedVoteChoice { + proposal_id: 3, + choice: Some(1), + }, + VotingCompletedVoteChoice { + proposal_id: 4, + choice: None, + }, + ] + ); + assert_eq!(display.voted_at, Some(42)); + } + + #[test] + fn delegation_recovery_from_maps_all_fields() { + let fork = ForkDelegationRecovery { + bundle_index: 1, + phase: DelegationPhase::Confirmed, + tx_hash: None, + van_leaf_position: Some(2), + }; + let mirror = VotingDelegationRecovery::from(fork); + assert_eq!(mirror.bundle_index, 1); + assert_eq!(mirror.phase, "confirmed"); + assert_eq!(mirror.workflow_phase, "confirmed"); + assert_eq!(mirror.tx_hash, None); + assert_eq!(mirror.van_leaf_position, Some(2)); + } + + #[test] + fn vote_recovery_from_maps_all_fields() { + let fork = ForkVoteRecovery { + bundle_index: 1, + proposal_id: 2, + choice: 3, + phase: VotePhase::Submitted, + tx_hash: Some("t".to_string()), + vc_tree_position: Some(9), + has_commitment_bundle: true, + }; + let mirror = VotingVoteRecovery::from(fork); + assert_eq!(mirror.bundle_index, 1); + assert_eq!(mirror.proposal_id, 2); + assert_eq!(mirror.choice, 3); + assert_eq!(mirror.phase, "submitted"); + assert_eq!(mirror.workflow_phase, "submitted_vote"); + assert_eq!(mirror.tx_hash.as_deref(), Some("t")); + assert_eq!(mirror.vc_tree_position, Some(9)); + assert!(mirror.has_commitment_bundle); + } + + #[test] + fn share_workflow_from_maps_all_fields() { + let fork = ForkShareWorkflow { + bundle_index: 1, + proposal_id: 2, + share_index: 3, + phase: SharePhase::Confirmed, + }; + let mirror = VotingShareWorkflow::from(fork); + assert_eq!(mirror.bundle_index, 1); + assert_eq!(mirror.proposal_id, 2); + assert_eq!(mirror.share_index, 3); + assert_eq!(mirror.phase, "confirmed"); + } + + #[test] + fn share_delegation_record_from_maps_all_fields() { + let fork = ForkShareDelegationRecord { + round_id: "r".to_string(), + bundle_index: 1, + proposal_id: 2, + share_index: 3, + sent_to_urls: vec!["u1".to_string()], + nullifier: vec![1, 2, 3], + confirmed: true, + submit_at: 4, + created_at: 5, + }; + assert_eq!( + VotingShareDelegationRecord::from(fork), + VotingShareDelegationRecord { + round_id: "r".to_string(), + bundle_index: 1, + proposal_id: 2, + share_index: 3, + sent_to_urls: vec!["u1".to_string()], + nullifier: vec![1, 2, 3], + confirmed: true, + submit_at: 4, + created_at: 5, + } + ); + } + + #[test] + fn round_recovery_from_maps_all_fields() { + let fork = ForkRoundRecovery { + round_id: "r".to_string(), + bundle_count: 2, + delegation: vec![ForkDelegationRecovery { + bundle_index: 1, + phase: DelegationPhase::Proved, + tx_hash: None, + van_leaf_position: None, + }], + votes: vec![ForkVoteRecovery { + bundle_index: 1, + proposal_id: 2, + choice: 3, + phase: VotePhase::Committed, + tx_hash: None, + vc_tree_position: None, + has_commitment_bundle: true, + }], + commitment_bundles: vec![], + shares: vec![ForkShareWorkflow { + bundle_index: 1, + proposal_id: 2, + share_index: 3, + phase: SharePhase::Submitted, + }], + share_delegations: vec![ + ForkShareDelegationRecord { + round_id: "r".to_string(), + bundle_index: 1, + proposal_id: 2, + share_index: 3, + sent_to_urls: vec!["u1".to_string()], + nullifier: vec![1], + confirmed: false, + submit_at: 4, + created_at: 5, + }, + ForkShareDelegationRecord { + round_id: "r".to_string(), + bundle_index: 1, + proposal_id: 2, + share_index: 4, + sent_to_urls: vec![], + nullifier: vec![2], + confirmed: true, + submit_at: 6, + created_at: 7, + }, + ], + unconfirmed_share_delegations: vec![ForkShareDelegationRecord { + round_id: "r".to_string(), + bundle_index: 1, + proposal_id: 2, + share_index: 5, + sent_to_urls: vec![], + nullifier: vec![3], + confirmed: false, + submit_at: 8, + created_at: 9, + }], + }; + let mirror = VotingRoundRecovery::from(fork); + assert_eq!(mirror.round_id, "r"); + assert_eq!(mirror.bundle_count, 2); + assert_eq!(mirror.delegation.len(), 1); + assert_eq!(mirror.votes.len(), 1); + assert_eq!(mirror.shares.len(), 1); + assert_eq!(mirror.share_delegations.len(), 2); + assert_eq!(mirror.unconfirmed_share_delegations.len(), 1); + assert_eq!(mirror.votes[0].phase, "committed"); + assert_eq!(mirror.shares[0].phase, "submitted"); + } + + #[test] + fn ballot_intent_from_maps_choice_and_skipped() { + assert_eq!( + VotingBallotIntent::from((7u32, Decision::Choice(3))), + VotingBallotIntent { + proposal_id: 7, + skipped: false, + choice: Some(3), + } + ); + assert_eq!( + VotingBallotIntent::from((7u32, Decision::Skipped)), + VotingBallotIntent { + proposal_id: 7, + skipped: true, + choice: None, + } + ); + } + + #[test] + fn share_tracking_summary_from_maps_all_fields() { + let fork = ShareTrackingSummary { + total: 10, + confirmed: 6, + waiting: 2, + ready: 1, + overdue: 1, + }; + assert_eq!( + VotingShareTrackingSummary::from(fork), + VotingShareTrackingSummary { + total: 10, + confirmed: 6, + waiting: 2, + ready: 1, + overdue: 1, + } + ); + } + + #[test] + fn fork_network_string_maps_each_network() { + assert_eq!(fork_network_string(VotingNetwork::Mainnet), "mainnet"); + assert_eq!(fork_network_string(VotingNetwork::Testnet), "testnet"); + assert_eq!(fork_network_string(VotingNetwork::Regtest), "regtest"); + } + + #[test] + fn config_switch_kind_string_maps_each_kind() { + assert_eq!(config_switch_kind_string(ConfigSwitchKind::Unchanged), "unchanged"); + assert_eq!(config_switch_kind_string(ConfigSwitchKind::InitialLoad), "initial_load"); + assert_eq!( + config_switch_kind_string(ConfigSwitchKind::SameChainServiceUpdate), + "same_chain_service_update" + ); + assert_eq!( + config_switch_kind_string(ConfigSwitchKind::NewChainOrRound), + "new_chain_or_round" + ); + assert_eq!( + config_switch_kind_string(ConfigSwitchKind::ProtocolChanged), + "protocol_changed" + ); + } + + #[test] + fn votechain_proxy_uses_external_proxy_only_for_transport_three() { + assert_eq!( + votechain_proxy(&coin(3, "socks5://127.0.0.1:1080")), + "socks5://127.0.0.1:1080" + ); + assert_eq!(votechain_proxy(&coin(3, "")), ""); + assert_eq!(votechain_proxy(&coin(0, "socks5://x")), ""); + assert_eq!(votechain_proxy(&coin(1, "socks5://x")), ""); + assert_eq!(votechain_proxy(&coin(2, "socks5://x")), ""); + } + + fn resolved_config(pir_layout: PirLayout) -> ResolvedVotingConfig { + ResolvedVotingConfig { + source_fingerprint: "sf".to_string(), + trusted_key_fingerprint: "tf".to_string(), + dynamic_config_fingerprint: "df".to_string(), + vote_servers: vec![ + ServiceEndpoint { + url: "https://v1".to_string(), + label: "vote".to_string(), + }, + ServiceEndpoint { + url: "https://v2".to_string(), + label: "vote2".to_string(), + }, + ], + pir_endpoints: vec![ServiceEndpoint { + url: "https://p".to_string(), + label: "pir".to_string(), + }], + pir_layout, + supported_versions: SupportedVersions { + pir: vec!["1".to_string()], + vote_protocol: "v1".to_string(), + tally: "t1".to_string(), + vote_server: "s1".to_string(), + }, + authenticated_rounds: vec![ + AuthenticatedRound { + round_id: "r1".to_string(), + ea_pk: vec![1, 2], + }, + AuthenticatedRound { + round_id: "r2".to_string(), + ea_pk: vec![3], + }, + ], + skipped_round_ids: vec!["r9".to_string()], + conditions: vec![ConfigCondition { + kind: ConfigConditionKind::VersionsSupported, + status: true, + message: "m".to_string(), + }], + } + } + + #[test] + fn voting_config_from_resolved_maps_all_fields() { + let layout = PirLayout { + pir_depth: 4, + tier0_layers: 2, + tier1_layers: 3, + poly_len: 2048, + }; + let config = VotingConfig::from_resolved( + "https://src".to_string(), + &resolved_config(layout), + ConfigSwitchKind::NewChainOrRound, + ); + assert_eq!(config.source, "https://src"); + assert_eq!(config.source_fingerprint, "sf"); + assert_eq!(config.trusted_key_fingerprint, "tf"); + assert_eq!(config.switch_kind, "new_chain_or_round"); + assert_eq!( + config.vote_servers, + vec![ + VotingServiceEndpoint { + url: "https://v1".to_string(), + label: "vote".to_string(), + }, + VotingServiceEndpoint { + url: "https://v2".to_string(), + label: "vote2".to_string(), + }, + ] + ); + assert_eq!( + config.pir_servers, + vec![VotingServiceEndpoint { + url: "https://p".to_string(), + label: "pir".to_string(), + }] + ); + assert_eq!( + config.pir_layout, + Some(VotingPirLayout { + pir_depth: 4, + tier0_layers: 2, + tier1_layers: 3, + poly_len: 2048, + }) + ); + assert_eq!( + config.rounds, + vec![ + VotingConfigRound { + round_id: "r1".to_string(), + ea_pk: vec![1, 2], + }, + VotingConfigRound { + round_id: "r2".to_string(), + ea_pk: vec![3], + }, + ] + ); + } + + #[test] + fn voting_config_from_resolved_maps_unknown_pir_layout_to_none() { + let config = VotingConfig::from_resolved( + "https://src".to_string(), + &resolved_config(PirLayout::UNKNOWN), + ConfigSwitchKind::InitialLoad, + ); + assert_eq!(config.pir_layout, None); + assert_eq!(config.switch_kind, "initial_load"); + } + + #[test] + fn share_delivery_parses_dart_wire_json() { + let json = r#"{"share_index":1,"sent_to_urls":["https://h1","https://h2"],"submit_at":42,"confirmed":true}"#; + let parsed: VotingShareDelivery = serde_json::from_str(json).unwrap(); + assert_eq!( + parsed, + VotingShareDelivery { + share_index: 1, + sent_to_urls: vec!["https://h1".to_string(), "https://h2".to_string()], + submit_at: 42, + confirmed: true, + } + ); + } + + #[test] + fn progress_and_stage_enums_serde_round_trip() { + assert_round_trips(VotingDelegationProgress::SelectingNotes); + assert_round_trips(VotingDelegationProgress::ProofProgress { progress: 0.25 }); + assert_round_trips(VotingVoteCommitStage::Signing { + proposal_id: 1, + bundle_index: 2, + }); + assert_round_trips(VotingVoteCommitStage::ProofProgress { + proposal_id: 3, + bundle_index: 4, + progress: 0.5, + }); + } + + #[test] + fn leaf_flow_structs_serde_round_trip() { + assert_round_trips(VotingDelegationSetup { + pczt_bytes: vec![1], + pczt_sighash: vec![2u8; 32], + rk: vec![3u8; 32], + action_index: 4, + action_bytes: vec![5], + tx1_effects: vec![6], + }); + assert_round_trips(VotingDelegationSubmission { + proof: vec![1], + rk: vec![2u8; 32], + nf_signed: vec![3u8; 32], + cmx_new: vec![4u8; 32], + gov_comm: vec![5u8; 32], + gov_nullifiers: vec![vec![6u8; 32], vec![7u8; 32]], + alpha: vec![8u8; 32], + vote_round_id: "r".to_string(), + spend_auth_sig: vec![9u8; 64], + sighash: vec![10u8; 32], + tx1_effects: vec![11], + }); + assert_round_trips(VotingDelegationConfirmation { + tx_hash: "0x1".to_string(), + van_leaf_position: 1, + }); + assert_round_trips(VotingVoteConfirmation { + tx_hash: "0x2".to_string(), + van_leaf_position: 2, + vc_tree_position: 3, + }); + assert_round_trips(VotingVanWitness { + auth_path: vec![vec![1], vec![2, 3]], + position: 4, + anchor_height: 5, + }); + assert_round_trips(VotingVoteSubmission { + vote_round_id: "r".to_string(), + proposal_id: 1, + van_nullifier: vec![2u8; 32], + vote_authority_note_new: vec![3u8; 32], + vote_commitment: vec![4u8; 32], + proof: vec![5], + r_vpk: vec![6u8; 32], + vote_auth_sig: vec![7u8; 64], + anchor_height: 8, + }); + assert_round_trips(VotingEncryptedShare { + c1: vec![1], + c2: vec![2], + share_index: 3, + }); + } + + #[test] + fn nested_and_config_structs_serde_round_trip() { + assert_round_trips(VotingSharePayload { + shares_hash: vec![1], + proposal_id: 2, + vote_decision: 3, + enc_share: VotingEncryptedShare { + c1: vec![4], + c2: vec![5], + share_index: 6, + }, + tree_position: 7, + all_enc_shares: vec![VotingEncryptedShare { + c1: vec![8], + c2: vec![9], + share_index: 10, + }], + share_comms: vec![vec![11]], + primary_blind: vec![12], + }); + assert_round_trips(VotingShareDelivery { + share_index: 1, + sent_to_urls: vec!["https://h1".to_string()], + submit_at: 42, + confirmed: false, + }); + assert_round_trips(VotingRoundInfo { + round_id: "r".to_string(), + network: "regtest".to_string(), + snapshot_height: 100, + hotkey_address: None, + eligible_weight_zatoshi: Some(50_000), + bundle_count: 2, + created_at: 123, + }); + assert_round_trips(VotingBallotIntent { + proposal_id: 1, + skipped: false, + choice: Some(2), + }); + assert_round_trips(VotingShareTrackingSummary { + total: 10, + confirmed: 6, + waiting: 2, + ready: 1, + overdue: 1, + }); + assert_round_trips(VotingConfig { + source: "https://src".to_string(), + source_fingerprint: "sf".to_string(), + trusted_key_fingerprint: "tf".to_string(), + switch_kind: "initial_load".to_string(), + vote_servers: vec![VotingServiceEndpoint { + url: "https://v".to_string(), + label: "vote".to_string(), + }], + pir_servers: vec![], + pir_layout: Some(VotingPirLayout { + pir_depth: 4, + tier0_layers: 2, + tier1_layers: 3, + poly_len: 2048, + }), + rounds: vec![VotingConfigRound { + round_id: "r1".to_string(), + ea_pk: vec![1, 2], + }], + }); + assert_round_trips(VotingDelegationBuild { + submission: VotingDelegationSubmission { + proof: vec![1], + rk: vec![2u8; 32], + nf_signed: vec![3u8; 32], + cmx_new: vec![4u8; 32], + gov_comm: vec![5u8; 32], + gov_nullifiers: vec![], + alpha: vec![6u8; 32], + vote_round_id: "r".to_string(), + spend_auth_sig: vec![7u8; 64], + sighash: vec![8u8; 32], + tx1_effects: vec![9], + }, + wire_json: "{}".to_string(), + }); + assert_round_trips(VotingVotePayloads { + submission: VotingVoteSubmission { + vote_round_id: "r".to_string(), + proposal_id: 1, + van_nullifier: vec![2u8; 32], + vote_authority_note_new: vec![3u8; 32], + vote_commitment: vec![4u8; 32], + proof: vec![5], + r_vpk: vec![6u8; 32], + vote_auth_sig: vec![7u8; 64], + anchor_height: 8, + }, + share_payloads: vec![], + }); + assert_round_trips(VotingRoundPlan { + round_id: "r".to_string(), + pending_recovery: false, + next_steps: vec![], + open_proposals: vec![], + all_decided: true, + delegation_statuses: vec![], + blocking_recovery: false, + blocking_share_work: false, + hotkey_bound: false, + completed_vote_artifact: false, + completed_for_display: false, + completed_vote_display: None, + needs_draft_setup: false, + primary_action: "done".to_string(), + }); + assert_round_trips(VotingRoundRecovery { + round_id: "r".to_string(), + bundle_count: 0, + delegation: vec![], + votes: vec![], + shares: vec![], + share_delegations: vec![], + unconfirmed_share_delegations: vec![], + }); + assert_round_trips(VotingSharePlan { + summary: VotingShareTrackingSummary { + total: 1, + confirmed: 0, + waiting: 1, + ready: 0, + overdue: 0, + }, + next_tracking_delay_secs: Some(30), + last_moment: false, + submissions: vec![VotingSharePlanItem { + submit_at: 100, + target_count: 1, + target_servers: vec!["https://h".to_string()], + }], + }); + } } diff --git a/rust/src/db.rs b/rust/src/db.rs index a072d39e9..2bc213e38 100644 --- a/rust/src/db.rs +++ b/rust/src/db.rs @@ -585,18 +585,32 @@ pub async fn migrate_sapling_addresses( network: &Network, connection: &mut SqliteConnection, ) -> Result<()> { + // Cover both empty and NULL addresses: older schemas left the column + // NULL, newer ones default it to ''. let accounts: Vec<(u32, u32, Vec)> = sqlx::query_as( "SELECT id_account, dindex, xvk FROM accounts a JOIN sapling_accounts s ON a.id_account = s.account - WHERE address = ''", + WHERE address IS NULL OR address = ''", ) .fetch_all(&mut *connection) .await?; for (account, dindex, xvk) in accounts { - let fvk: [u8; 128] = tiu!(xvk); - let fvk = DiversifiableFullViewingKey::from_bytes(&fvk).unwrap(); - let address = fvk.address((dindex as u64).into()).unwrap(); + // A row that cannot be backfilled must not abort the migration (or + // panic the runtime): skip it, and the lenient address readers will + // report it as missing. + let Ok(fvk) = <[u8; 128]>::try_from(xvk.as_slice()) else { + tracing::warn!("sapling xvk for account {account} is not 128 bytes; skipping backfill"); + continue; + }; + let Some(fvk) = DiversifiableFullViewingKey::from_bytes(&fvk) else { + tracing::warn!("invalid sapling xvk for account {account}; skipping backfill"); + continue; + }; + let Some(address) = fvk.address((dindex as u64).into()) else { + tracing::warn!("invalid diversifier {dindex} for account {account}; skipping backfill"); + continue; + }; let address = address.encode(network); sqlx::query("UPDATE sapling_accounts SET address = ?2 WHERE account = ?1") .bind(account) @@ -648,7 +662,7 @@ pub async fn backfill_diversifier_index(connection: &mut SqliteConnection) -> Re // Check if any notes need backfilling let count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM notes WHERE pool IN (1, 2) AND diversifier IS NOT NULL AND diversifier_index IS NULL", + "SELECT COUNT(*) FROM notes WHERE diversifier IS NOT NULL AND diversifier_index IS NULL", ) .fetch_one(&mut *connection) .await?; @@ -663,7 +677,7 @@ pub async fn backfill_diversifier_index(connection: &mut SqliteConnection) -> Re // Fetch distinct accounts with unbackfilled notes let accounts: Vec<(u32,)> = sqlx::query_as( - "SELECT DISTINCT account FROM notes WHERE pool IN (1, 2) AND diversifier IS NOT NULL AND diversifier_index IS NULL", + "SELECT DISTINCT account FROM notes WHERE diversifier IS NOT NULL AND diversifier_index IS NULL", ) .fetch_all(&mut *connection) .await?; @@ -691,7 +705,7 @@ pub async fn backfill_diversifier_index(connection: &mut SqliteConnection) -> Re // Fetch unbackfilled notes for this account let notes: Vec<(u32, u8, u8, Vec)> = sqlx::query_as( - "SELECT id_note, pool, scope, diversifier FROM notes WHERE account = ? AND pool IN (1, 2) AND diversifier IS NOT NULL AND diversifier_index IS NULL", + "SELECT id_note, pool, scope, diversifier FROM notes WHERE account = ? AND diversifier IS NOT NULL AND diversifier_index IS NULL", ) .bind(account) .fetch_all(&mut *connection) @@ -702,7 +716,7 @@ pub async fn backfill_diversifier_index(connection: &mut SqliteConnection) -> Re 1 => sapling_dfvk .as_ref() .and_then(|dfvk| resolve_sapling_diversifier_index(dfvk, scope, &diversifier)), - 2 => orchard_fvk + 2 | 3 => orchard_fvk .as_ref() .and_then(|fvk| resolve_orchard_diversifier_index(fvk, scope, &diversifier)), _ => None, @@ -1130,7 +1144,15 @@ pub async fn select_account_sapling( }), xvk: xvk .map(|xvk| DiversifiableFullViewingKey::from_bytes(&xvk.try_into().unwrap()).unwrap()), - address: address.map(|a| PaymentAddress::decode(network, &a).unwrap()), + // A corrupt or empty stored address (e.g. rows imported before the + // address was backfilled) must not panic the runtime: surface a + // clear error instead. + address: match address { + Some(a) => Some(PaymentAddress::decode(network, &a).map_err(|e| { + anyhow!("invalid stored sapling address for account {account}: {e}") + })?), + None => None, + }, }; Ok(keys) diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 66fbae43f..6c4df0e52 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -42,7 +42,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1766202353; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -353494689; // Section: executor @@ -7974,6 +7974,49 @@ fn wire__crate__api__voting__voting_confirm_impl( }, ) } +fn wire__crate__api__voting__voting_delegation_van_commitment_hex_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_delegation_van_commitment_hex", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_round_id = ::sse_decode(&mut deserializer); + let api_bundle_index = ::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::voting::voting_delegation_van_commitment_hex( + &api_round_id, + api_bundle_index, + &api_c, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__voting__voting_drafts_load_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -8055,6 +8098,45 @@ fn wire__crate__api__voting__voting_drafts_save_impl( }, ) } +fn wire__crate__api__voting__voting_eligible_weight_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_eligible_weight", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_snapshot_height = ::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::api::voting::voting_eligible_weight(api_snapshot_height, &api_c) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__voting__voting_hotkey_create_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -8313,6 +8395,101 @@ fn wire__crate__api__voting__voting_record_execution_impl( }, ) } +fn wire__crate__api__voting__voting_recover_confirm_delegation_from_tree_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_recover_confirm_delegation_from_tree", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_round_id = ::sse_decode(&mut deserializer); + let api_bundle_index = ::sse_decode(&mut deserializer); + let api_van_leaf_position = ::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::api::voting::voting_recover_confirm_delegation_from_tree( + &api_round_id, + api_bundle_index, + api_van_leaf_position, + &api_c, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__voting__voting_recover_confirm_vote_from_tree_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_recover_confirm_vote_from_tree", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_round_id = ::sse_decode(&mut deserializer); + let api_bundle_index = ::sse_decode(&mut deserializer); + let api_proposal_id = ::sse_decode(&mut deserializer); + let api_vc_tree_position = ::sse_decode(&mut deserializer); + let api_van_leaf_position = >::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::voting::voting_recover_confirm_vote_from_tree( + &api_round_id, + api_bundle_index, + api_proposal_id, + api_vc_tree_position, + api_van_leaf_position, + &api_c, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__voting__voting_recovery_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -8390,6 +8567,45 @@ fn wire__crate__api__voting__voting_recovery_clear_impl( }, ) } +fn wire__crate__api__voting__voting_reset_session_state_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_reset_session_state", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_round_id = ::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::api::voting::voting_reset_session_state(&api_round_id, &api_c) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__voting__voting_round_params_json_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -8475,6 +8691,44 @@ fn wire__crate__api__voting__voting_rounds_impl( }, ) } +fn wire__crate__api__voting__voting_sessions_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_sessions", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_round_ids = >::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::api::voting::voting_sessions(api_round_ids, &api_c).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__voting__voting_set_ballot_intent_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -8620,6 +8874,45 @@ fn wire__crate__api__voting__voting_share_confirm_impl( }, ) } +fn wire__crate__api__voting__voting_share_payloads_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_share_payloads", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_round_id = ::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::api::voting::voting_share_payloads(&api_round_id, &api_c) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__voting__voting_share_plan_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -8653,13 +8946,205 @@ fn wire__crate__api__voting__voting_share_plan_impl( move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( (move || async move { - let output_ok = crate::api::voting::voting_share_plan( + let output_ok = crate::api::voting::voting_share_plan( + &api_round_id, + api_now, + api_ceremony_start, + api_vote_end, + api_server_urls, + api_single_share, + &api_c, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__voting__voting_share_plans_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_share_plans", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_share_count = ::sse_decode(&mut deserializer); + let api_server_urls = >::sse_decode(&mut deserializer); + let api_now = ::sse_decode(&mut deserializer); + let api_vote_end = ::sse_decode(&mut deserializer); + let api_ceremony_start = ::sse_decode(&mut deserializer); + let api_single_share = ::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::voting::voting_share_plans( + api_share_count, + api_server_urls, + api_now, + api_vote_end, + api_ceremony_start, + api_single_share, + &api_c, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__voting__voting_share_record_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_share_record", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_round_id = ::sse_decode(&mut deserializer); + let api_bundle_index = ::sse_decode(&mut deserializer); + let api_proposal_id = ::sse_decode(&mut deserializer); + let api_share_index = ::sse_decode(&mut deserializer); + let api_sent_to_urls = >::sse_decode(&mut deserializer); + let api_submit_at = ::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::voting::voting_share_record( + &api_round_id, + api_bundle_index, + api_proposal_id, + api_share_index, + api_sent_to_urls, + api_submit_at, + &api_c, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__voting__voting_share_unconfirmed_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_share_unconfirmed", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_round_id = ::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::api::voting::voting_share_unconfirmed(&api_round_id, &api_c) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__api__voting__voting_share_wire_json_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "voting_share_wire_json", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_round_id = ::sse_decode(&mut deserializer); + let api_bundle_index = ::sse_decode(&mut deserializer); + let api_proposal_id = ::sse_decode(&mut deserializer); + let api_share_index = ::sse_decode(&mut deserializer); + let api_vc_tree_position = >::sse_decode(&mut deserializer); + let api_submit_at = ::sse_decode(&mut deserializer); + let api_c = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::voting::voting_share_wire_json( &api_round_id, - api_now, - api_ceremony_start, - api_vote_end, - api_server_urls, - api_single_share, + api_bundle_index, + api_proposal_id, + api_share_index, + api_vc_tree_position, + api_submit_at, &api_c, ) .await?; @@ -8671,7 +9156,7 @@ fn wire__crate__api__voting__voting_share_plan_impl( }, ) } -fn wire__crate__api__voting__voting_share_record_impl( +fn wire__crate__api__voting__voting_sync_tree_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -8679,7 +9164,7 @@ fn wire__crate__api__voting__voting_share_record_impl( ) { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "voting_share_record", + debug_name: "voting_sync_tree", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, }, @@ -8694,23 +9179,15 @@ fn wire__crate__api__voting__voting_share_record_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_round_id = ::sse_decode(&mut deserializer); - let api_bundle_index = ::sse_decode(&mut deserializer); - let api_proposal_id = ::sse_decode(&mut deserializer); - let api_share_index = ::sse_decode(&mut deserializer); - let api_sent_to_urls = >::sse_decode(&mut deserializer); - let api_submit_at = ::sse_decode(&mut deserializer); + let api_vote_node_url = ::sse_decode(&mut deserializer); let api_c = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( (move || async move { - let output_ok = crate::api::voting::voting_share_record( + let output_ok = crate::api::voting::voting_sync_tree( &api_round_id, - api_bundle_index, - api_proposal_id, - api_share_index, - api_sent_to_urls, - api_submit_at, + &api_vote_node_url, &api_c, ) .await?; @@ -8722,7 +9199,7 @@ fn wire__crate__api__voting__voting_share_record_impl( }, ) } -fn wire__crate__api__voting__voting_share_unconfirmed_impl( +fn wire__crate__api__voting__voting_tree_find_leaf_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -8730,7 +9207,7 @@ fn wire__crate__api__voting__voting_share_unconfirmed_impl( ) { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "voting_share_unconfirmed", + debug_name: "voting_tree_find_leaf", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, }, @@ -8745,14 +9222,18 @@ fn wire__crate__api__voting__voting_share_unconfirmed_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_round_id = ::sse_decode(&mut deserializer); - let api_c = ::sse_decode(&mut deserializer); + let api_node_url = ::sse_decode(&mut deserializer); + let api_target_hex = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( (move || async move { - let output_ok = - crate::api::voting::voting_share_unconfirmed(&api_round_id, &api_c) - .await?; + let output_ok = crate::api::voting::voting_tree_find_leaf( + &api_round_id, + &api_node_url, + &api_target_hex, + ) + .await?; Ok(output_ok) })() .await, @@ -8761,7 +9242,7 @@ fn wire__crate__api__voting__voting_share_unconfirmed_impl( }, ) } -fn wire__crate__api__voting__voting_share_wire_json_impl( +fn wire__crate__api__voting__voting_van_witness_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -8769,7 +9250,7 @@ fn wire__crate__api__voting__voting_share_wire_json_impl( ) { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "voting_share_wire_json", + debug_name: "voting_van_witness", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, }, @@ -8785,22 +9266,16 @@ fn wire__crate__api__voting__voting_share_wire_json_impl( flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_round_id = ::sse_decode(&mut deserializer); let api_bundle_index = ::sse_decode(&mut deserializer); - let api_proposal_id = ::sse_decode(&mut deserializer); - let api_share_index = ::sse_decode(&mut deserializer); - let api_vc_tree_position = >::sse_decode(&mut deserializer); - let api_submit_at = ::sse_decode(&mut deserializer); + let api_vote_node_url = ::sse_decode(&mut deserializer); let api_c = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( (move || async move { - let output_ok = crate::api::voting::voting_share_wire_json( + let output_ok = crate::api::voting::voting_van_witness( &api_round_id, api_bundle_index, - api_proposal_id, - api_share_index, - api_vc_tree_position, - api_submit_at, + &api_vote_node_url, &api_c, ) .await?; @@ -8812,7 +9287,7 @@ fn wire__crate__api__voting__voting_share_wire_json_impl( }, ) } -fn wire__crate__api__voting__voting_sync_tree_impl( +fn wire__crate__api__voting__voting_vote_commitment_hex_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -8820,7 +9295,7 @@ fn wire__crate__api__voting__voting_sync_tree_impl( ) { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "voting_sync_tree", + debug_name: "voting_vote_commitment_hex", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, }, @@ -8835,15 +9310,17 @@ fn wire__crate__api__voting__voting_sync_tree_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_round_id = ::sse_decode(&mut deserializer); - let api_vote_node_url = ::sse_decode(&mut deserializer); + let api_bundle_index = ::sse_decode(&mut deserializer); + let api_proposal_id = ::sse_decode(&mut deserializer); let api_c = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( (move || async move { - let output_ok = crate::api::voting::voting_sync_tree( + let output_ok = crate::api::voting::voting_vote_commitment_hex( &api_round_id, - &api_vote_node_url, + api_bundle_index, + api_proposal_id, &api_c, ) .await?; @@ -8855,7 +9332,7 @@ fn wire__crate__api__voting__voting_sync_tree_impl( }, ) } -fn wire__crate__api__voting__voting_van_witness_impl( +fn wire__crate__api__voting__voting_vote_van_commitment_hex_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -8863,7 +9340,7 @@ fn wire__crate__api__voting__voting_van_witness_impl( ) { FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( flutter_rust_bridge::for_generated::TaskInfo { - debug_name: "voting_van_witness", + debug_name: "voting_vote_van_commitment_hex", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, }, @@ -8879,16 +9356,16 @@ fn wire__crate__api__voting__voting_van_witness_impl( flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_round_id = ::sse_decode(&mut deserializer); let api_bundle_index = ::sse_decode(&mut deserializer); - let api_vote_node_url = ::sse_decode(&mut deserializer); + let api_proposal_id = ::sse_decode(&mut deserializer); let api_c = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( (move || async move { - let output_ok = crate::api::voting::voting_van_witness( + let output_ok = crate::api::voting::voting_vote_van_commitment_hex( &api_round_id, api_bundle_index, - &api_vote_node_url, + api_proposal_id, &api_c, ) .await?; @@ -10009,6 +10486,20 @@ impl SseDecode for Vec { } } +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode( + deserializer, + )); + } + return ans_; + } +} + impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -10063,6 +10554,18 @@ impl SseDecode for Vec { } } +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -11164,9 +11667,11 @@ impl SseDecode for crate::api::voting::VotingChainResponse { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { let mut var_statusCode = ::sse_decode(deserializer); let mut var_body = ::sse_decode(deserializer); + let mut var_retryAfterSecs = >::sse_decode(deserializer); return crate::api::voting::VotingChainResponse { status_code: var_statusCode, body: var_body, + retry_after_secs: var_retryAfterSecs, }; } } @@ -11533,6 +12038,23 @@ impl SseDecode for crate::api::voting::VotingRoundRecovery { } } +impl SseDecode for crate::api::voting::VotingRoundSession { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_roundId = ::sse_decode(deserializer); + let mut var_plan = ::sse_decode(deserializer); + let mut var_recovery = ::sse_decode(deserializer); + let mut var_intents = + >::sse_decode(deserializer); + return crate::api::voting::VotingRoundSession { + round_id: var_roundId, + plan: var_plan, + recovery: var_recovery, + intents: var_intents, + }; + } +} + impl SseDecode for crate::api::voting::VotingServiceEndpoint { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -11628,6 +12150,22 @@ impl SseDecode for crate::api::voting::VotingSharePlanItem { } } +impl SseDecode for crate::api::voting::VotingShareSubmissionPayload { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_bundleIndex = ::sse_decode(deserializer); + let mut var_proposalId = ::sse_decode(deserializer); + let mut var_shareIndex = ::sse_decode(deserializer); + let mut var_vcTreePosition = >::sse_decode(deserializer); + return crate::api::voting::VotingShareSubmissionPayload { + bundle_index: var_bundleIndex, + proposal_id: var_proposalId, + share_index: var_shareIndex, + vc_tree_position: var_vcTreePosition, + }; + } +} + impl SseDecode for crate::api::voting::VotingShareTrackingSummary { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -11692,6 +12230,18 @@ impl SseDecode for crate::api::voting::VotingSignedVoteCommitment { } } +impl SseDecode for crate::api::voting::VotingTreeVoteConfirmation { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_vcTreePosition = ::sse_decode(deserializer); + let mut var_vanLeafPosition = >::sse_decode(deserializer); + return crate::api::voting::VotingTreeVoteConfirmation { + vc_tree_position: var_vcTreePosition, + van_leaf_position: var_vanLeafPosition, + }; + } +} + impl SseDecode for crate::api::voting::VotingVanWitness { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -12267,68 +12817,115 @@ fn pde_ffi_dispatcher_primary_impl( wire__crate__api__voting__voting_config_resolve_impl(port, ptr, rust_vec_len, data_len) } 201 => wire__crate__api__voting__voting_confirm_impl(port, ptr, rust_vec_len, data_len), - 202 => wire__crate__api__voting__voting_drafts_load_impl(port, ptr, rust_vec_len, data_len), - 203 => wire__crate__api__voting__voting_drafts_save_impl(port, ptr, rust_vec_len, data_len), - 204 => { + 202 => wire__crate__api__voting__voting_delegation_van_commitment_hex_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 203 => wire__crate__api__voting__voting_drafts_load_impl(port, ptr, rust_vec_len, data_len), + 204 => wire__crate__api__voting__voting_drafts_save_impl(port, ptr, rust_vec_len, data_len), + 205 => { + wire__crate__api__voting__voting_eligible_weight_impl(port, ptr, rust_vec_len, data_len) + } + 206 => { wire__crate__api__voting__voting_hotkey_create_impl(port, ptr, rust_vec_len, data_len) } - 205 => wire__crate__api__voting__voting_hotkey_get_impl(port, ptr, rust_vec_len, data_len), - 206 => wire__crate__api__voting__voting_mark_vote_submitted_impl( + 207 => wire__crate__api__voting__voting_hotkey_get_impl(port, ptr, rust_vec_len, data_len), + 208 => wire__crate__api__voting__voting_mark_vote_submitted_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 209 => wire__crate__api__voting__voting_payloads_impl(port, ptr, rust_vec_len, data_len), + 210 => wire__crate__api__voting__voting_plan_impl(port, ptr, rust_vec_len, data_len), + 211 => wire__crate__api__voting__voting_record_execution_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 212 => wire__crate__api__voting__voting_recover_confirm_delegation_from_tree_impl( port, ptr, rust_vec_len, data_len, ), - 207 => wire__crate__api__voting__voting_payloads_impl(port, ptr, rust_vec_len, data_len), - 208 => wire__crate__api__voting__voting_plan_impl(port, ptr, rust_vec_len, data_len), - 209 => wire__crate__api__voting__voting_record_execution_impl( + 213 => wire__crate__api__voting__voting_recover_confirm_vote_from_tree_impl( port, ptr, rust_vec_len, data_len, ), - 210 => wire__crate__api__voting__voting_recovery_impl(port, ptr, rust_vec_len, data_len), - 211 => { + 214 => wire__crate__api__voting__voting_recovery_impl(port, ptr, rust_vec_len, data_len), + 215 => { wire__crate__api__voting__voting_recovery_clear_impl(port, ptr, rust_vec_len, data_len) } - 212 => wire__crate__api__voting__voting_round_params_json_impl( + 216 => wire__crate__api__voting__voting_reset_session_state_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 217 => wire__crate__api__voting__voting_round_params_json_impl( port, ptr, rust_vec_len, data_len, ), - 213 => wire__crate__api__voting__voting_rounds_impl(port, ptr, rust_vec_len, data_len), - 214 => wire__crate__api__voting__voting_set_ballot_intent_impl( + 218 => wire__crate__api__voting__voting_rounds_impl(port, ptr, rust_vec_len, data_len), + 219 => wire__crate__api__voting__voting_sessions_impl(port, ptr, rust_vec_len, data_len), + 220 => wire__crate__api__voting__voting_set_ballot_intent_impl( port, ptr, rust_vec_len, data_len, ), - 215 => wire__crate__api__voting__voting_share_add_servers_impl( + 221 => wire__crate__api__voting__voting_share_add_servers_impl( port, ptr, rust_vec_len, data_len, ), - 216 => { + 222 => { wire__crate__api__voting__voting_share_confirm_impl(port, ptr, rust_vec_len, data_len) } - 217 => wire__crate__api__voting__voting_share_plan_impl(port, ptr, rust_vec_len, data_len), - 218 => { + 223 => { + wire__crate__api__voting__voting_share_payloads_impl(port, ptr, rust_vec_len, data_len) + } + 224 => wire__crate__api__voting__voting_share_plan_impl(port, ptr, rust_vec_len, data_len), + 225 => wire__crate__api__voting__voting_share_plans_impl(port, ptr, rust_vec_len, data_len), + 226 => { wire__crate__api__voting__voting_share_record_impl(port, ptr, rust_vec_len, data_len) } - 219 => wire__crate__api__voting__voting_share_unconfirmed_impl( + 227 => wire__crate__api__voting__voting_share_unconfirmed_impl( port, ptr, rust_vec_len, data_len, ), - 220 => { + 228 => { wire__crate__api__voting__voting_share_wire_json_impl(port, ptr, rust_vec_len, data_len) } - 221 => wire__crate__api__voting__voting_sync_tree_impl(port, ptr, rust_vec_len, data_len), - 222 => wire__crate__api__voting__voting_van_witness_impl(port, ptr, rust_vec_len, data_len), - 223 => { + 229 => wire__crate__api__voting__voting_sync_tree_impl(port, ptr, rust_vec_len, data_len), + 230 => { + wire__crate__api__voting__voting_tree_find_leaf_impl(port, ptr, rust_vec_len, data_len) + } + 231 => wire__crate__api__voting__voting_van_witness_impl(port, ptr, rust_vec_len, data_len), + 232 => wire__crate__api__voting__voting_vote_commitment_hex_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 233 => wire__crate__api__voting__voting_vote_van_commitment_hex_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 234 => { wire__crate__api__voting__voting_vote_wire_json_impl(port, ptr, rust_vec_len, data_len) } _ => unreachable!(), @@ -13686,6 +14283,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::voting::VotingChainResponse { [ self.status_code.into_into_dart().into_dart(), self.body.into_into_dart().into_dart(), + self.retry_after_secs.into_into_dart().into_dart(), ] .into_dart() } @@ -14158,6 +14756,29 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::voting::VotingRoundSession { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.round_id.into_into_dart().into_dart(), + self.plan.into_into_dart().into_dart(), + self.recovery.into_into_dart().into_dart(), + self.intents.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::voting::VotingRoundSession +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::voting::VotingRoundSession +{ + fn into_into_dart(self) -> crate::api::voting::VotingRoundSession { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::voting::VotingServiceEndpoint { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -14279,6 +14900,29 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::voting::VotingShareSubmissionPayload { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.bundle_index.into_into_dart().into_dart(), + self.proposal_id.into_into_dart().into_dart(), + self.share_index.into_into_dart().into_dart(), + self.vc_tree_position.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::voting::VotingShareSubmissionPayload +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::voting::VotingShareSubmissionPayload +{ + fn into_into_dart(self) -> crate::api::voting::VotingShareSubmissionPayload { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::voting::VotingShareTrackingSummary { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -14356,6 +15000,27 @@ impl flutter_rust_bridge::IntoIntoDart flutter_rust_bridge::for_generated::DartAbi { + [ + self.vc_tree_position.into_into_dart().into_dart(), + self.van_leaf_position.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::voting::VotingTreeVoteConfirmation +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::voting::VotingTreeVoteConfirmation +{ + fn into_into_dart(self) -> crate::api::voting::VotingTreeVoteConfirmation { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::voting::VotingVanWitness { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -15378,6 +16043,16 @@ impl SseEncode for Vec { } } +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + impl SseEncode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -15418,6 +16093,16 @@ impl SseEncode for Vec { } } +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + impl SseEncode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -16220,6 +16905,7 @@ impl SseEncode for crate::api::voting::VotingChainResponse { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { ::sse_encode(self.status_code, serializer); ::sse_encode(self.body, serializer); + >::sse_encode(self.retry_after_secs, serializer); } } @@ -16464,6 +17150,16 @@ impl SseEncode for crate::api::voting::VotingRoundRecovery { } } +impl SseEncode for crate::api::voting::VotingRoundSession { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.round_id, serializer); + ::sse_encode(self.plan, serializer); + ::sse_encode(self.recovery, serializer); + >::sse_encode(self.intents, serializer); + } +} + impl SseEncode for crate::api::voting::VotingServiceEndpoint { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -16523,6 +17219,16 @@ impl SseEncode for crate::api::voting::VotingSharePlanItem { } } +impl SseEncode for crate::api::voting::VotingShareSubmissionPayload { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.bundle_index, serializer); + ::sse_encode(self.proposal_id, serializer); + ::sse_encode(self.share_index, serializer); + >::sse_encode(self.vc_tree_position, serializer); + } +} + impl SseEncode for crate::api::voting::VotingShareTrackingSummary { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -16561,6 +17267,14 @@ impl SseEncode for crate::api::voting::VotingSignedVoteCommitment { } } +impl SseEncode for crate::api::voting::VotingTreeVoteConfirmation { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.vc_tree_position, serializer); + >::sse_encode(self.van_leaf_position, serializer); + } +} + impl SseEncode for crate::api::voting::VotingVanWitness { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/rust/src/io.rs b/rust/src/io.rs index 099ab94fc..df73ba4d9 100644 --- a/rust/src/io.rs +++ b/rust/src/io.rs @@ -1,7 +1,7 @@ use std::{borrow::Cow, collections::HashMap}; use age::{scrypt::Identity, Decryptor, Encryptor}; -use anyhow::Result; +use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use serde_with::{hex::Hex, serde_as}; use sqlx::{ @@ -12,8 +12,10 @@ use sqlx::{ }; use std::io::prelude::*; use tracing::info; +use zcash_keys::keys::sapling::{DiversifiableFullViewingKey, ExtendedSpendingKey}; use zstd::DEFAULT_COMPRESSION_LEVEL; +use crate::api::coin::Network; use crate::db::DB_VERSION; pub async fn export_account(connection: &mut SqliteConnection, account: u32) -> Result> { @@ -474,7 +476,11 @@ pub async fn export_account(connection: &mut SqliteConnection, account: u32) -> Ok(data) } -pub async fn import_account(connection: &mut SqliteConnection, data: &[u8]) -> Result<()> { +pub async fn import_account( + network: &Network, + connection: &mut SqliteConnection, + data: &[u8], +) -> Result<()> { let mut decoder = zstd::Decoder::new(data)?; let mut data = String::new(); decoder.read_to_string(&mut data)?; @@ -593,6 +599,31 @@ pub async fn import_account(connection: &mut SqliteConnection, data: &[u8]) -> R .bind(&skeys.xvk) .execute(&mut *tx) .await?; + // The backup file carries only the sapling keys; the address column + // would stay at its empty default, which select_account_sapling + // cannot decode (it panicked on it). Derive the address from the + // imported keys, mirroring new_account's seed-restore derivation. + let sxvk = match skeys.xsk.as_ref() { + Some(xsk) => ExtendedSpendingKey::from_bytes(&xsk.0) + .map_err(|_| anyhow!("invalid sapling xsk in account backup"))? + .to_diversifiable_full_viewing_key(), + None => { + let bytes: [u8; 128] = skeys + .xvk + .0 + .as_slice() + .try_into() + .map_err(|_| anyhow!("invalid sapling xvk length in account backup"))?; + DiversifiableFullViewingKey::from_bytes(&bytes) + .ok_or_else(|| anyhow!("invalid sapling xvk in account backup"))? + } + }; + let address = crate::account::derive_sapling_address(network, &sxvk, io_account.dindex); + sqlx::query("UPDATE sapling_accounts SET address = ?2 WHERE account = ?1") + .bind(new_id_account) + .bind(&address) + .execute(&mut *tx) + .await?; } info!("Importing orchard key"); if let Some(okeys) = io_account.okeys.as_ref() { diff --git a/rust/src/ledger/builder.rs b/rust/src/ledger/builder.rs index 56ef9c37d..e6071a0eb 100644 --- a/rust/src/ledger/builder.rs +++ b/rust/src/ledger/builder.rs @@ -68,14 +68,6 @@ pub async fn sign_transaction( let coin_type = network.coin_type(); let aindex = get_account_aindex(&mut *connection, account).await?; - let (xvk,): (Vec,) = - sqlx::query_as("SELECT xvk FROM sapling_accounts WHERE account = ?1") - .bind(account) - .fetch_one(&mut *connection) - .await - .anyhow()?; - let fvk = FullViewingKey::read(&*xvk)?; - let ovk = fvk.ovk; let pczt = Pczt::parse(&package.pczt).expect("cannot parse PCZT"); @@ -94,6 +86,25 @@ pub async fn sign_transaction( return Err(LedgerError::TooComplex); } + // The Sapling full viewing key is only needed when the transaction + // actually contains Sapling spends/outputs. Transparent-only accounts + // (e.g. BIP-44 Ledger accounts) have no `sapling_accounts` row, so + // fetching it unconditionally would fail with `RowNotFound` and abort + // signing before the Ledger is ever contacted. The values derived from + // it (`fvk`, `ovk`) are only referenced inside the Sapling loops below, + // which only iterate when Sapling components are present. + let fvk = if stin > 0 || stout > 0 { + let (xvk,): (Vec,) = + sqlx::query_as("SELECT xvk FROM sapling_accounts WHERE account = ?1") + .bind(account) + .fetch_one(&mut *connection) + .await + .anyhow()?; + Some(FullViewingKey::read(&*xvk)?) + } else { + None + }; + // Signing a tx with the Ledger involves several steps // Step 1. Send a InitTx instruction with inputs/outputs let _ = sink.add(SigningEvent::Progress("Init Tx".to_string())); @@ -162,6 +173,7 @@ pub async fn sign_transaction( .await.anyhow()?; let diversifier = Diversifier(tiu!(diversifier)); + let fvk = fvk.as_ref().expect("fvk present for Sapling spends"); let recipient = fvk.vk.to_payment_address(diversifier).unwrap(); let mut data = vec![]; data.write_u32::(aindex)?; @@ -173,6 +185,8 @@ pub async fn sign_transaction( let mut memos = vec![]; for sout in pczt.sapling().outputs().iter() { + let fvk = fvk.as_ref().expect("fvk present for Sapling outputs"); + let ovk = fvk.ovk; let recipient = sout.recipient().unwrap(); // Decrypt the memo so that we can reencrypt it with the // randomness @@ -292,6 +306,7 @@ pub async fn sign_transaction( let rcm: [u8; 32] = tiu!(rcm); let rcv: [u8; 32] = tiu!(rcv); let diversifier = Diversifier(tiu!(diversifier)); + let fvk = fvk.as_ref().expect("fvk present for Sapling spends"); let recipient = fvk.vk.to_payment_address(diversifier).unwrap(); let rseed = Rseed::BeforeZip212(Fr::from_bytes(&rcm).unwrap()); let note = Note::from_parts(recipient, NoteValue::from_raw(value), rseed); @@ -317,6 +332,8 @@ pub async fn sign_transaction( data: vec![], }; for (out, memo) in pczt.sapling().outputs().iter().zip(memos.iter()) { + let fvk = fvk.as_ref().expect("fvk present for Sapling outputs"); + let ovk = fvk.ovk; let _ = sink.add(SigningEvent::Progress( "Extracting output randomness".to_string(), )); @@ -414,8 +431,9 @@ pub async fn sign_transaction( let pczt = if !pczt.sapling().spends().is_empty() || !pczt.sapling().outputs().is_empty() { let updater = Updater::new(pczt); + let fvk = fvk.as_ref().expect("fvk present for Sapling proofs"); let nsk = Fr::from_bytes(&nsk).unwrap(); - let pgk = ProofGenerationKey { ak: fvk.vk.ak, nsk }; + let pgk = ProofGenerationKey { ak: fvk.vk.ak.clone(), nsk }; let updater = updater .update_sapling_with(|mut u| { @@ -470,10 +488,19 @@ pub async fn sign_transaction( buffers.push(data); } // Read zkproof from sapling-crypto types (pczt types don't expose it) - let anchor = pczt - .sapling() - .anchor() - .expect("a Sapling bundle with spends must have an anchor"); + // The Sapling anchor is only present when there are Sapling spends. A + // transparent-only transaction has no Sapling bundle, so `anchor()` is + // `None`; only fetch it when there are spends to serialize. + let anchor = if stin > 0 { + Some( + pczt + .sapling() + .anchor() + .expect("a Sapling bundle with spends must have an anchor"), + ) + } else { + None + }; // Use update_sapling_with to access sapling-crypto Spend/Output which have full // getters including zkproof() let mut proof_bufs: Vec> = vec![]; @@ -485,7 +512,10 @@ pub async fn sign_transaction( for sin in bundle.spends() { let mut data = vec![]; data.write_all(&sin.cv().to_bytes()).unwrap(); - data.write_all(&anchor).unwrap(); + data.write_all( + anchor.as_ref().expect("anchor present for Sapling spends"), + ) + .unwrap(); data.write_all(sin.nullifier().as_ref()).unwrap(); let rk_bytes: [u8; 32] = VerificationKeyBytes::from(*sin.rk()).into(); data.write_all(&rk_bytes).unwrap(); diff --git a/rust/src/net/nym_service.rs b/rust/src/net/nym_service.rs index 606ffb048..9fd72aeb0 100644 --- a/rust/src/net/nym_service.rs +++ b/rust/src/net/nym_service.rs @@ -24,8 +24,9 @@ use nym_sdk::mixnet::{ IncludedSurbs, MixnetClient, MixnetClientBuilder, MixnetMessageSender, NymNetworkDetails, Recipient, }; -use nym_sdk::tcp_proxy::utils::{MessageBuffer, Payload, ProxiedMessage}; -use tokio::net::{TcpListener, TcpStream}; +use nym_sdk::tcp_proxy::utils::{Payload, ProxiedMessage}; +use tokio::io::AsyncWriteExt; +use tokio::net::{tcp::OwnedWriteHalf, TcpListener, TcpStream}; use tokio::sync::{oneshot, Mutex, OnceCell}; use tokio_stream::StreamExt; use tokio_util::codec::{BytesCodec, FramedRead}; @@ -152,6 +153,60 @@ async fn get_client() -> Result { Ok(client) } +/// Strictly ordered reassembly buffer for a session's incoming messages. +/// +/// nym-sdk's `MessageBuffer` flushes any message that has waited longer than +/// 6s even when earlier ids are still missing ("decay-based delivery"), which +/// reorders bytes under mixnet retransmission delays and corrupts the h2 +/// stream (GOAWAY FRAME_SIZE_ERROR). gRPC needs exact byte order, so this +/// buffer only ever writes consecutive message ids; a permanently missing id +/// is handled by the session stall watchdog instead of by corrupting data. +struct OrderedMessageBuffer { + next_id: u16, + pending: HashMap, + /// Set while writes are blocked on a missing id; cleared on progress. + blocked_since: Option, +} + +impl OrderedMessageBuffer { + fn new() -> Self { + OrderedMessageBuffer { + next_id: 0, + pending: HashMap::new(), + blocked_since: None, + } + } + + fn push(&mut self, msg: ProxiedMessage) { + // Ignore duplicates of already-written ids (mixnet retransmissions). + if msg.message_id >= self.next_id { + self.pending.insert(msg.message_id, msg.message); + } + } + + /// Write every consecutive payload to the local socket. Returns `true` + /// when the session's Close message has been reached. + async fn flush(&mut self, write: &mut OwnedWriteHalf) -> Result { + while let Some(payload) = self.pending.remove(&self.next_id) { + self.next_id += 1; + self.blocked_since = None; + match payload { + Payload::Data(data) => write.write_all(&data).await?, + Payload::Close => return Ok(true), + } + } + if !self.pending.is_empty() && self.blocked_since.is_none() { + self.blocked_since = Some(Instant::now()); + } + Ok(false) + } + + /// How long writes have been blocked on a missing message id. + fn blocked_for(&self) -> Option { + self.blocked_since.map(|t| t.elapsed()) + } +} + /// One ordered mixnet session per local TCP connection, mirroring nym-rpc's /// `TcpProxyClient::handle_incoming`. async fn run_session(stream: TcpStream, recipient: Recipient) -> Result<()> { @@ -201,30 +256,39 @@ async fn run_session(stream: TcpStream, recipient: Recipient) -> Result<()> { Ok::<_, anyhow::Error>(()) }); - // Incoming: reorder mixnet messages and write them to the local socket; - // after local EOF keep draining for CLOSE_TIMEOUT. - let mut msg_buffer = MessageBuffer::new(); + // Incoming: reorder mixnet messages and write them to the local socket + // in strict id order; after local EOF keep draining for CLOSE_TIMEOUT. + let mut msg_buffer = OrderedMessageBuffer::new(); loop { tokio::select! { _ = &mut rx => break, Some(message) = client.next() => { let message = bincode1::deserialize::(&message.message)?; msg_buffer.push(message); - msg_buffer.tick(&mut write).await?; + if msg_buffer.flush(&mut write).await? { + tracing::debug!("nym-rpc session {session_id}: Close received"); + client.disconnect().await; + return Ok(()); + } last_in = started.elapsed().as_secs().max(1); }, _ = tokio::time::sleep(Duration::from_millis(100)) => { - msg_buffer.tick(&mut write).await?; // Stall: we sent a request after the last reply and the - // mixnet has returned nothing since. Drop the session so - // the caller gets an error instead of waiting forever. + // mixnet has returned nothing since, or replies keep + // arriving but a missing id has blocked writes for just as + // long. Drop the session so the caller gets an error + // instead of waiting forever. let out = last_out.load(Ordering::Relaxed); - if out > last_in + let request_stalled = out > last_in && started.elapsed().as_secs().saturating_sub(last_in.max(out)) - > STALL_TIMEOUT.as_secs() - { + > STALL_TIMEOUT.as_secs(); + let gap_stalled = msg_buffer + .blocked_for() + .is_some_and(|blocked| blocked > STALL_TIMEOUT); + if request_stalled || gap_stalled { tracing::warn!( - "nym-rpc session {session_id} stalled (no reply for {}s); closing", + "nym-rpc session {session_id} stalled ({} for {}s); closing", + if gap_stalled { "message gap unfilled" } else { "no reply" }, STALL_TIMEOUT.as_secs() ); client.disconnect().await; @@ -238,7 +302,11 @@ async fn run_session(stream: TcpStream, recipient: Recipient) -> Result<()> { Some(message) = client.next() => { let message = bincode1::deserialize::(&message.message)?; msg_buffer.push(message); - msg_buffer.tick(&mut write).await?; + if msg_buffer.flush(&mut write).await? { + tracing::debug!("nym-rpc session {session_id}: Close received"); + client.disconnect().await; + return Ok(()); + } }, _ = tokio::time::sleep(CLOSE_TIMEOUT) => { tracing::debug!("nym-rpc session {session_id} closed"); diff --git a/rust/src/net/votechain.rs b/rust/src/net/votechain.rs index b97c066a4..55550fb3f 100644 --- a/rust/src/net/votechain.rs +++ b/rust/src/net/votechain.rs @@ -6,46 +6,108 @@ //! //! JSON envelopes are returned as raw bodies — the vote-sdk schema is still //! evolving and the Dart UI parses leniently (mirroring vizor's client). -//! HTTP status is preserved for 2xx, 404, and 422 (422 = deterministic chain -//! rejection whose body is a `VotingTxResult`), so the UI can distinguish a -//! rejection from a transport error; only network failures produce `Err`. +//! Every completed HTTP response is an answer: the status code, body, and +//! `Retry-After` header are passed through unchanged (422 = deterministic +//! chain rejection whose body is a `VotingTxResult`; 404 = not found; 5xx = +//! server-side trouble the caller may fail over from). Only transport +//! failures produce `Err`. +//! +//! Transient failures retry in place: idempotent GETs retry on any transport +//! error, and POSTs retry only when the request never reached the server (a +//! connection error), so a resent submission cannot double-deliver. Failover +//! across the configured vote servers is the Dart caller's job. use anyhow::{anyhow, Result}; use std::time::Duration; +use tokio::time::sleep; + +/// Attempts for idempotent vote-chain reads. A gateway 500 or a dropped +/// connection must not fail a whole voting run. +const GET_ATTEMPTS: usize = 3; +/// Attempts for vote-chain writes. Only connection failures retry — the +/// request never reached the server — anything else (including a timeout +/// after the request was sent) is final for this server. +const POST_ATTEMPTS: usize = 2; +/// Backoff between in-place retry attempts. +const RETRY_DELAY: Duration = Duration::from_millis(500); -/// Returns the status code and body of a GET, without erroring on 404. -async fn get(base_url: &str, path: &str, proxy: &str) -> Result<(u16, String)> { +/// Returns the status code, body, and `Retry-After` of a GET. Every completed +/// HTTP response is an answer; only transport failures are `Err`. +async fn get(base_url: &str, path: &str, proxy: &str) -> Result<(u16, String, Option)> { let url = endpoint(base_url, path)?; + let mut last_err: Option = None; + for attempt in 0..GET_ATTEMPTS { + if attempt > 0 { + sleep(RETRY_DELAY * attempt as u32).await; + } + match get_once(&url, proxy).await { + Ok(response) => return Ok(response), + Err(e) => last_err = Some(e), + } + } + Err(last_err.expect("GET_ATTEMPTS is at least 1")) +} + +/// One GET attempt; 404 and other non-2xx statuses are answers, not errors. +async fn get_once(url: &str, proxy: &str) -> Result<(u16, String, Option)> { let response = client(proxy, Duration::from_secs(15))? - .get(&url) + .get(url) .send() .await .map_err(|e| anyhow!("vote chain GET {url}: {e}"))?; let status = response.status().as_u16(); + let retry_after = retry_after_secs(response.headers()); let body = response.text().await?; - if status != 404 && (status < 200 || status >= 300) { - return Err(anyhow!("vote chain GET {url}: HTTP {status}: {body}")); - } - Ok((status, body)) + Ok((status, body, retry_after)) } -/// Returns the status code and body of a POST, without erroring on 422 -/// (deterministic chain rejection). -async fn post(base_url: &str, path: &str, body_json: &str, proxy: &str) -> Result<(u16, String)> { +/// Returns the status code, body, and `Retry-After` of a POST. Every completed +/// HTTP response (including 422 deterministic rejections and 5xx) is an +/// answer; only transport failures are `Err`. +async fn post( + base_url: &str, + path: &str, + body_json: &str, + proxy: &str, +) -> Result<(u16, String, Option)> { let url = endpoint(base_url, path)?; - let response = client(proxy, Duration::from_secs(60))? - .post(&url) - .header("content-type", "application/json") - .body(body_json.to_string()) - .send() - .await - .map_err(|e| anyhow!("vote chain POST {url}: {e}"))?; - let status = response.status().as_u16(); - let body = response.text().await?; - if status != 422 && (status < 200 || status >= 300) { - return Err(anyhow!("vote chain POST {url}: HTTP {status}: {body}")); + let mut last_err: Option = None; + for attempt in 0..POST_ATTEMPTS { + if attempt > 0 { + sleep(RETRY_DELAY).await; + } + let send = client(proxy, Duration::from_secs(60))? + .post(&url) + .header("content-type", "application/json") + .body(body_json.to_string()) + .send() + .await; + let response = match send { + Ok(response) => response, + Err(e) => { + let err = anyhow!("vote chain POST {url}: {e}"); + if e.is_connect() { + last_err = Some(err); + continue; + } + return Err(err); + } + }; + let status = response.status().as_u16(); + let retry_after = retry_after_secs(response.headers()); + let body = response.text().await?; + return Ok((status, body, retry_after)); } - Ok((status, body)) + Err(last_err.expect("POST_ATTEMPTS is at least 1")) +} + +/// Parses a `Retry-After` header in seconds (the vote-sdk sends an integer +/// delay; HTTP-date values are ignored). +fn retry_after_secs(headers: &reqwest::header::HeaderMap) -> Option { + headers + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|raw| raw.trim().parse::().ok()) } /// Builds a `/shielded-vote/v1/...` URL under `base_url`. @@ -66,17 +128,25 @@ fn client(proxy: &str, timeout: Duration) -> Result { /// Lists rounds from the vote server. Current vote-sdk returns /// `{ "rounds": [...] }`; an empty `{}` means no rounds. -pub async fn list_rounds(base_url: &str, proxy: &str) -> Result<(u16, String)> { +pub async fn list_rounds(base_url: &str, proxy: &str) -> Result<(u16, String, Option)> { get(base_url, "rounds", proxy).await } /// Fetches one round's status (`{ "round": ... }` envelope). -pub async fn round_status(base_url: &str, round_id: &str, proxy: &str) -> Result<(u16, String)> { +pub async fn round_status( + base_url: &str, + round_id: &str, + proxy: &str, +) -> Result<(u16, String, Option)> { get(base_url, &format!("round/{round_id}"), proxy).await } /// Fetches the round tally envelope (`tally-results`). -pub async fn round_tally(base_url: &str, round_id: &str, proxy: &str) -> Result<(u16, String)> { +pub async fn round_tally( + base_url: &str, + round_id: &str, + proxy: &str, +) -> Result<(u16, String, Option)> { get(base_url, &format!("tally-results/{round_id}"), proxy).await } @@ -85,7 +155,7 @@ pub async fn submit_delegation( base_url: &str, submission_json: &str, proxy: &str, -) -> Result<(u16, String)> { +) -> Result<(u16, String, Option)> { post(base_url, "delegate-vote", submission_json, proxy).await } @@ -94,7 +164,7 @@ pub async fn submit_vote_commitment( base_url: &str, commitment_json: &str, proxy: &str, -) -> Result<(u16, String)> { +) -> Result<(u16, String, Option)> { post(base_url, "cast-vote", commitment_json, proxy).await } @@ -103,7 +173,7 @@ pub async fn tx_confirmation( base_url: &str, tx_hash: &str, proxy: &str, -) -> Result<(u16, String)> { +) -> Result<(u16, String, Option)> { get(base_url, &format!("tx/{tx_hash}"), proxy).await } @@ -113,7 +183,7 @@ pub async fn submit_share( server_url: &str, payload_json: &str, proxy: &str, -) -> Result<(u16, String)> { +) -> Result<(u16, String, Option)> { post(server_url, "shares", payload_json, proxy).await } @@ -123,12 +193,28 @@ pub async fn share_status( round_id: &str, share_id: &str, proxy: &str, -) -> Result<(u16, String)> { +) -> Result<(u16, String, Option)> { get(server_url, &format!("share-status/{round_id}/{share_id}"), proxy).await } -/// Fetches raw bytes from an arbitrary URL (voting config blobs). +/// Fetches raw bytes from an arbitrary URL (voting config blobs). Idempotent, +/// so it retries like [get]. pub async fn fetch_bytes(url: &str, proxy: &str) -> Result> { + let mut last_err: Option = None; + for attempt in 0..GET_ATTEMPTS { + if attempt > 0 { + sleep(RETRY_DELAY * attempt as u32).await; + } + match fetch_bytes_once(url, proxy).await { + Ok(bytes) => return Ok(bytes), + Err(e) => last_err = Some(e), + } + } + Err(last_err.expect("GET_ATTEMPTS is at least 1")) +} + +/// One config-blob fetch attempt. +async fn fetch_bytes_once(url: &str, proxy: &str) -> Result> { let response = client(proxy, Duration::from_secs(15))? .get(url) .send() diff --git a/rust/src/openalias.rs b/rust/src/openalias.rs index 9bad1e06d..5b73d0806 100644 --- a/rust/src/openalias.rs +++ b/rust/src/openalias.rs @@ -57,7 +57,9 @@ fn split_oa1_pairs(body: &str) -> Vec<&str> { match b { b'"' => in_quotes = !in_quotes, b';' if !in_quotes => { - let pair = body[start..i].trim(); + // Trim leading only: a trailing space before `;` may be the + // second char of a `\ ` escape and belongs to the value. + let pair = body[start..i].trim_start(); if !pair.is_empty() { pairs.push(pair); } @@ -66,7 +68,7 @@ fn split_oa1_pairs(body: &str) -> Vec<&str> { _ => {} } } - let last = body[start..].trim(); + let last = body[start..].trim_start(); if !last.is_empty() { pairs.push(last); } @@ -81,16 +83,34 @@ fn parse_oa1_pair(pair: &str) -> Option<(String, String)> { let (key, val) = pair.split_once('=')?; let key = key.trim().to_string(); - let val = val.trim(); - let val = if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 { - val[1..val.len() - 1].to_string() + let trimmed = val.trim(); + let val = if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 { + // Quoted value: strip quotes, decode escaped spaces inside. + trimmed[1..trimmed.len() - 1].replace("\\ ", " ") } else { - val.replace("\\ ", " ") + // Unquoted value: decode `\ ` escapes on the raw value. + unescape_oa1_value(val) }; Some((key, val)) } +/// Decode `\ ` escapes in an unquoted OA1 value. +/// +/// Mirrors the openalias-rs grammar (`"\\ "* ... "\\ "*`): unescaped +/// leading/trailing spaces are dropped, but spaces encoded by a trailing +/// `\ ` run survive (e.g. `foo\ ` → `foo `). +fn unescape_oa1_value(raw: &str) -> String { + let raw = raw.trim_start(); + let mut tail = String::new(); + let mut end = raw.len(); + while raw[..end].ends_with("\\ ") { + end -= 2; + tail.push(' '); + } + format!("{}{}", raw[..end].trim_end().replace("\\ ", " "), tail) +} + /// Parse an OA1 TXT record string into an [`Oa1Record`]. /// /// Format: `oa1: key1=val1; key2="val2"; ...` diff --git a/rust/src/pay/plan.rs b/rust/src/pay/plan.rs index 19aac0388..dd967084d 100644 --- a/rust/src/pay/plan.rs +++ b/rust/src/pay/plan.rs @@ -31,6 +31,7 @@ use zcash_keys::{address::UnifiedAddress, encoding::AddressCodec as _}; use zcash_note_encryption::Domain; use zcash_primitives::transaction::{ builder::{BuildConfig, Builder, BundlePadding}, + TxVersion, fees::zip317::FeeRule, }; use zcash_proofs::prover::LocalTxProver; @@ -697,6 +698,17 @@ pub async fn plan_transaction( }; let mut builder = Builder::new(network, BlockHeight::from_u32(target_height), build_config); + // Hardware (Ledger) wallets only support v5 (ZIP-244) transaction signing. + // The Zondax "Zcash Shielded" app predates NU6.3 and cannot sign v6/Ironwood + // transactions, so force a v5 tx while keeping consensus_branch_id = Nu6_3 + // (V5 is valid in Nu6_3 per TxVersion::valid_in_branch). A v5 tx carrying the + // current Nu6_3 branch id is valid on the network. + if hw != 0 { + builder + .propose_version::<()>(TxVersion::V5) + .map_err(|e| anyhow!("failed to force v5 for hardware signing: {e:?}"))?; + } + let es = es.to_auth_path(&SaplingHasher::default()); let eo = eo.to_auth_path(&OrchardHasher::default()); let ei = ei.to_auth_path(&OrchardHasher::default()); @@ -1557,17 +1569,26 @@ pub async fn get_sapling_prover() -> Result<&'static LocalTxProver> { static PROVER: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); PROVER .get_or_try_init(|| async { - let params_dir = crate::api::sapling::resolve_params_dir() - .ok_or_else(|| anyhow::anyhow!("Failed to resolve Sapling parameters directory"))?; - let spend_path = params_dir.join(zcash_proofs::SAPLING_SPEND_NAME); - let output_path = params_dir.join(zcash_proofs::SAPLING_OUTPUT_NAME); + #[cfg(feature = "bundled-sapling-params")] + { + // Parameters compiled into the binary — never touch disk or network. + return Ok(LocalTxProver::bundled()); + } + #[cfg(not(feature = "bundled-sapling-params"))] + { + let params_dir = crate::api::sapling::resolve_params_dir().ok_or_else(|| { + anyhow::anyhow!("Failed to resolve Sapling parameters directory") + })?; + let spend_path = params_dir.join(zcash_proofs::SAPLING_SPEND_NAME); + let output_path = params_dir.join(zcash_proofs::SAPLING_OUTPUT_NAME); - if spend_path.exists() && output_path.exists() { - return Ok(LocalTxProver::new(&spend_path, &output_path)); + if spend_path.exists() && output_path.exists() { + return Ok(LocalTxProver::new(&spend_path, &output_path)); + } + // Parameters not found on disk — download them. + crate::api::sapling::download_sapling_params().await?; + Ok(LocalTxProver::new(&spend_path, &output_path)) } - // Parameters not found on disk — download them. - crate::api::sapling::download_sapling_params().await?; - Ok(LocalTxProver::new(&spend_path, &output_path)) }) .await } diff --git a/rust/src/plugin/mod.rs b/rust/src/plugin/mod.rs index 5723caa15..fd11f8a17 100644 --- a/rust/src/plugin/mod.rs +++ b/rust/src/plugin/mod.rs @@ -4,7 +4,7 @@ //! 1. **Install**: download archive → extract to `$DATADIR/plugins//` → validate manifest → //! create engine → compile script → call `get_prefixes()` → store in DB. //! 2. **Load (startup)**: read all enabled plugins from DB → compile AST → cache AST. -//! 3. **Dispatch**: given memo bytes, find plugins whose prefixes match → call `process_memo()`. +//! 3. **Dispatch**: given memo bytes, find plugins whose prefixes match → call `process_memo(memo)`. //! 4. **Remove**: delete plugin directory → remove DB row → evict AST cache. pub mod db; @@ -22,7 +22,7 @@ use std::sync::OnceLock; use crate::api::coin::Coin; use crate::plugin::db as plugin_db; use crate::plugin::rhai_api::{ - create_sandboxed_engine, extract_prefixes, extract_sections, with_memo_bytes, ParsedMemoCell, + create_sandboxed_engine, extract_prefixes, extract_sections, Memo, ParsedMemoCell, ParsedMemoSection, }; @@ -223,18 +223,17 @@ pub async fn parse_memo_with_plugins(c: &Coin, memo_bytes: &[u8]) -> Result(&mut Scope::new(), &ast, "process_memo", ()) { - Ok(result) => { - for section in extract_sections(result) { - sections.push(MemoSection::from(section)); - } - } - Err(e) => { - tracing::warn!("Plugin process_memo failed: {e}"); + let memo = Memo::new(payload.to_vec()); + match engine.call_fn::(&mut Scope::new(), &ast, "process_memo", (memo,)) { + Ok(result) => { + for section in extract_sections(result) { + sections.push(MemoSection::from(section)); } } - }); + Err(e) => { + tracing::warn!("Plugin process_memo failed: {e}"); + } + } } Ok(sections) diff --git a/rust/src/plugin/rhai_api.rs b/rust/src/plugin/rhai_api.rs index 6b65c7f87..d8304738f 100644 --- a/rust/src/plugin/rhai_api.rs +++ b/rust/src/plugin/rhai_api.rs @@ -1,111 +1,103 @@ //! Rhai sandbox API exposed to plugin scripts. //! -//! Scripts call `memo::read_u8(offset)`, `cell_string("value")`, `section(...)`, etc. -//! The memo bytes are stored in a global `Mutex` before calling `process_memo` so the -//! `memo::*` functions can read from them implicitly. - -use rhai::{Dynamic, Engine, EvalAltResult, Module}; -use std::sync::{LazyLock, Mutex}; - -/// Global memo payload for the currently-executing script. -static CURRENT_MEMO: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); - -/// Set the memo bytes, execute a closure, then clear the bytes. -pub fn with_memo_bytes(bytes: &[u8], f: F) -> R -where - F: FnOnce() -> R, -{ - { - let mut guard = CURRENT_MEMO.lock().unwrap(); - *guard = bytes.to_vec(); - } - let result = f(); - { - let mut guard = CURRENT_MEMO.lock().unwrap(); - guard.clear(); - } - result -} +//! Scripts call `process_memo(memo)` where `memo` is a [`Memo`] value passed +//! explicitly by the host, and read it via methods like `memo.read_u8(offset)`, +//! `memo.read_string(offset, len)`, etc. The memo bytes are per-call state — +//! no globals. -// ── memo module functions (fallible for Module::set_native_fn) ───────── +use rhai::{Blob, Dynamic, Engine}; +#[cfg(test)] +use rhai::Scope; + +/// Memo payload exposed to plugin scripts. +/// +/// Passed to `process_memo(memo)` as an argument. Provides the read methods +/// scripts use to decode the memo. +#[derive(Debug, Clone)] +pub struct Memo { + data: Blob, +} -type RhaiResult = Result>; +impl Memo { + pub fn new(data: Vec) -> Self { + Self { data: data.into() } + } -fn memo_len() -> RhaiResult { - Ok(CURRENT_MEMO.lock().unwrap().len() as i64) -} + fn len(&mut self) -> i64 { + self.data.len() as i64 + } -fn memo_read_u8(offset: i64) -> RhaiResult { - let idx = offset as usize; - let guard = CURRENT_MEMO.lock().unwrap(); - Ok(if idx < guard.len() { - guard[idx] as i64 - } else { - 0 - }) -} + fn read_u8(&mut self, offset: i64) -> i64 { + let idx = offset as usize; + if idx < self.data.len() { + self.data[idx] as i64 + } else { + 0 + } + } -fn memo_read_u16_le(offset: i64) -> RhaiResult { - let idx = offset as usize; - let guard = CURRENT_MEMO.lock().unwrap(); - Ok(if idx + 1 < guard.len() { - u16::from_le_bytes([guard[idx], guard[idx + 1]]) as i64 - } else { - 0 - }) -} + fn read_u16_le(&mut self, offset: i64) -> i64 { + let idx = offset as usize; + if idx + 1 < self.data.len() { + u16::from_le_bytes([self.data[idx], self.data[idx + 1]]) as i64 + } else { + 0 + } + } -fn memo_read_u32_le(offset: i64) -> RhaiResult { - let idx = offset as usize; - let guard = CURRENT_MEMO.lock().unwrap(); - Ok(if idx + 3 < guard.len() { - u32::from_le_bytes([guard[idx], guard[idx + 1], guard[idx + 2], guard[idx + 3]]) as i64 - } else { - 0 - }) -} + fn read_u32_le(&mut self, offset: i64) -> i64 { + let idx = offset as usize; + if idx + 3 < self.data.len() { + u32::from_le_bytes([ + self.data[idx], + self.data[idx + 1], + self.data[idx + 2], + self.data[idx + 3], + ]) as i64 + } else { + 0 + } + } -fn memo_read_u64_le(offset: i64) -> RhaiResult { - let idx = offset as usize; - let guard = CURRENT_MEMO.lock().unwrap(); - Ok(if idx + 7 < guard.len() { - u64::from_le_bytes([ - guard[idx], - guard[idx + 1], - guard[idx + 2], - guard[idx + 3], - guard[idx + 4], - guard[idx + 5], - guard[idx + 6], - guard[idx + 7], - ]) as i64 - } else { - 0 - }) -} + fn read_u64_le(&mut self, offset: i64) -> i64 { + let idx = offset as usize; + if idx + 7 < self.data.len() { + u64::from_le_bytes([ + self.data[idx], + self.data[idx + 1], + self.data[idx + 2], + self.data[idx + 3], + self.data[idx + 4], + self.data[idx + 5], + self.data[idx + 6], + self.data[idx + 7], + ]) as i64 + } else { + 0 + } + } -fn memo_read_bytes(offset: i64, len: i64) -> RhaiResult { - let idx = offset.max(0) as usize; - let n = len.max(0) as usize; - let guard = CURRENT_MEMO.lock().unwrap(); - let end = (idx + n).min(guard.len()); - Ok(guard[idx..end].to_vec()) -} + fn read_bytes(&mut self, offset: i64, len: i64) -> Blob { + let idx = offset.max(0) as usize; + let n = len.max(0) as usize; + let end = (idx + n).min(self.data.len()); + self.data[idx..end].to_vec() + } -fn memo_read_string(offset: i64, len: i64) -> RhaiResult { - let idx = offset.max(0) as usize; - let n = len.max(0) as usize; - let guard = CURRENT_MEMO.lock().unwrap(); - let end = (idx + n).min(guard.len()); - let slice = &guard[idx..end]; - // Trim trailing zeros - let slice = match slice.iter().position(|&b| b == 0) { - Some(zero_pos) => &slice[..zero_pos], - None => slice, - }; - Ok(String::from_utf8_lossy(slice) - .trim_end_matches('\0') - .to_string()) + fn read_string(&mut self, offset: i64, len: i64) -> String { + let idx = offset.max(0) as usize; + let n = len.max(0) as usize; + let end = (idx + n).min(self.data.len()); + let slice = &self.data[idx..end]; + // Trim trailing zeros + let slice = match slice.iter().position(|&b| b == 0) { + Some(zero_pos) => &slice[..zero_pos], + None => slice, + }; + String::from_utf8_lossy(slice) + .trim_end_matches('\0') + .to_string() + } } // ── Constructor functions (global, non-fallible) ─────────────────────── @@ -161,16 +153,16 @@ pub fn create_sandboxed_engine() -> Engine { engine.disable_symbol("call"); engine.disable_symbol("import"); - // Register memo namespace module (memo::read_u8, etc.) - let mut memo_module = Module::new(); - memo_module.set_native_fn("len", memo_len); - memo_module.set_native_fn("read_u8", memo_read_u8); - memo_module.set_native_fn("read_u16_le", memo_read_u16_le); - memo_module.set_native_fn("read_u32_le", memo_read_u32_le); - memo_module.set_native_fn("read_u64_le", memo_read_u64_le); - memo_module.set_native_fn("read_bytes", memo_read_bytes); - memo_module.set_native_fn("read_string", memo_read_string); - engine.register_static_module("memo", memo_module.into()); + // Register the Memo type: scripts receive it as `process_memo(memo)` + // and call methods like `memo.read_u8(4)` on it. + engine.register_type::(); + engine.register_fn("len", Memo::len); + engine.register_fn("read_u8", Memo::read_u8); + engine.register_fn("read_u16_le", Memo::read_u16_le); + engine.register_fn("read_u32_le", Memo::read_u32_le); + engine.register_fn("read_u64_le", Memo::read_u64_le); + engine.register_fn("read_bytes", Memo::read_bytes); + engine.register_fn("read_string", Memo::read_string); // Register section/cell constructors (global) engine.register_fn("section", section as fn(&str, Dynamic, Dynamic) -> Dynamic); @@ -235,13 +227,11 @@ mod tests { #[test] fn test_memo_functions_out_of_bounds() { let engine = create_sandboxed_engine(); - let ast = engine - .compile("fn t() { memo::read_u8(1000) } t()") + let ast = engine.compile("fn t(memo) { memo.read_u8(1000) }").unwrap(); + let result: i64 = engine + .call_fn(&mut Scope::new(), &ast, "t", (Memo::new(vec![0x01, 0x02]),)) .unwrap(); - with_memo_bytes(&[0x01, 0x02], || { - let result: i64 = engine.eval_ast(&ast).unwrap(); - assert_eq!(result, 0, "OOB read should return 0"); - }); + assert_eq!(result, 0, "OOB read should return 0"); } #[test] @@ -249,15 +239,15 @@ mod tests { let engine = create_sandboxed_engine(); let data = vec![0xAA, 0xBB, 0x01, 0x00, 0x00, 0x00]; let ast = engine - .compile("fn t() { [memo::read_u8(0), memo::read_u32_le(2)] } t()") + .compile("fn t(memo) { [memo.read_u8(0), memo.read_u32_le(2)] }") + .unwrap(); + let result: Dynamic = engine + .call_fn(&mut Scope::new(), &ast, "t", (Memo::new(data),)) .unwrap(); - with_memo_bytes(&data, || { - let result: Dynamic = engine.eval_ast(&ast).unwrap(); - let json = serde_json::to_value(&result).unwrap(); - let arr = json.as_array().unwrap(); - assert_eq!(arr[0].as_i64().unwrap(), 0xAA); - assert_eq!(arr[1].as_i64().unwrap(), 1); - }); + let json = serde_json::to_value(&result).unwrap(); + let arr = json.as_array().unwrap(); + assert_eq!(arr[0].as_i64().unwrap(), 0xAA); + assert_eq!(arr[1].as_i64().unwrap(), 1); } #[test] @@ -304,21 +294,20 @@ mod tests { let data = vec![0xde, 0xad, 0xbe, 0xef, 0x01]; let script = r#" fn get_prefixes() { return ["deadbeef"]; } - fn process_memo() { - let version = memo::read_u8(4); + fn process_memo(memo) { + let version = memo.read_u8(4); let headers = ["Version"]; let rows = [[cell_number(version)]]; return [section("Test", headers, rows)]; } - process_memo() "#; let ast = engine.compile(script).unwrap(); - with_memo_bytes(&data, || { - let result: Dynamic = engine.eval_ast(&ast).unwrap(); - let sections = extract_sections(result); - assert_eq!(sections.len(), 1); - assert_eq!(sections[0].rows[0][0].value, "1"); - }); + let result: Dynamic = engine + .call_fn(&mut Scope::new(), &ast, "process_memo", (Memo::new(data),)) + .unwrap(); + let sections = extract_sections(result); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].rows[0][0].value, "1"); } #[test] @@ -328,12 +317,12 @@ mod tests { data[0..5].copy_from_slice(b"hello"); data[5] = 0; let ast = engine - .compile("fn t() { memo::read_string(0, 32) } t()") + .compile("fn t(memo) { memo.read_string(0, 32) }") + .unwrap(); + let result: String = engine + .call_fn(&mut Scope::new(), &ast, "t", (Memo::new(data),)) .unwrap(); - with_memo_bytes(&data, || { - let result: String = engine.eval_ast(&ast).unwrap(); - assert_eq!(result, "hello"); - }); + assert_eq!(result, "hello"); } #[test] @@ -348,9 +337,9 @@ mod tests { let script = r#" fn get_prefixes() { return ["444b3030"]; } - fn process_memo() { - let from_id = memo::read_u8(4); - let data_len = memo::read_u64_le(5); + fn process_memo(memo) { + let from_id = memo.read_u8(4); + let data_len = memo.read_u64_le(5); let headers = ["Field", "Value"]; let rows = [ [cell_string("Round"), cell_string("DKG Round 0")], @@ -359,17 +348,48 @@ mod tests { ]; return [section("DKG Message", headers, rows)]; } - process_memo() "#; let ast = engine.compile(script).unwrap(); - with_memo_bytes(&data, || { - let result: Dynamic = engine.eval_ast(&ast).unwrap(); - let sections = extract_sections(result); - assert_eq!(sections.len(), 1); - assert_eq!(sections[0].title, "DKG Message"); - assert_eq!(sections[0].rows[0][1].value, "DKG Round 0"); - assert_eq!(sections[0].rows[1][1].value, "1"); // from_id - assert_eq!(sections[0].rows[2][1].value, "32"); // data_len - }); + let result: Dynamic = engine + .call_fn(&mut Scope::new(), &ast, "process_memo", (Memo::new(data),)) + .unwrap(); + let sections = extract_sections(result); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].title, "DKG Message"); + assert_eq!(sections[0].rows[0][1].value, "DKG Round 0"); + assert_eq!(sections[0].rows[1][1].value, "1"); // from_id + assert_eq!(sections[0].rows[2][1].value, "32"); // data_len + } + + #[test] + fn test_process_memo_isolated_between_threads() { + // Each thread gets its own memo; values must not bleed across threads. + let engine = create_sandboxed_engine(); + let ast = engine + .compile("fn process_memo(memo) { memo.read_u8(0) }") + .unwrap(); + + let handles: Vec<_> = (0..8u8) + .map(|i| { + let ast = ast.clone(); + std::thread::spawn(move || { + let engine = create_sandboxed_engine(); + for _ in 0..100 { + let result: i64 = engine + .call_fn( + &mut Scope::new(), + &ast, + "process_memo", + (Memo::new(vec![i; 4]),), + ) + .unwrap(); + assert_eq!(result, i as i64); + } + }) + }) + .collect(); + for handle in handles { + handle.join().unwrap(); + } } } diff --git a/rust/src/sync.rs b/rust/src/sync.rs index 153dd92f5..46087d638 100644 --- a/rust/src/sync.rs +++ b/rust/src/sync.rs @@ -237,6 +237,10 @@ pub async fn synchronize_impl + Send + 'static>( ) .await?; + // Over the mixnet, download blocks in small chunks so each + // GetBlockRange stream fits in one nym-rpc session; elsewhere a + // single stream for the whole range is cheaper. + let chunk_size = c.is_mixnet().then_some(MIXNET_SYNC_CHUNK); shielded_sync( &network, &pool, @@ -245,6 +249,7 @@ pub async fn synchronize_impl + Send + 'static>( start_height, end_height, actions_per_sync, + chunk_size, tx_progress.clone(), tx_cancel.subscribe(), ) @@ -649,13 +654,20 @@ fn resolve_diversifier_index( 1 => cache.sapling.get(&account).and_then(|keys| { crate::db::resolve_sapling_diversifier_index(&keys.dfvk, scope, diversifier) }), - 2 => cache.orchard.get(&account).and_then(|keys| { + 2 | 3 => cache.orchard.get(&account).and_then(|keys| { crate::db::resolve_orchard_diversifier_index(&keys.fvk, scope, diversifier) }), _ => None, } } +/// Blocks per `GetBlockRange` request when syncing over the Nym mixnet. +/// A single full-range stream starves the nym-rpc session of reply SURBs +/// and trips its 120s stall watchdog; small chunks keep each stream within +/// one session's budget and commit progress between chunks, so a dropped +/// session only costs the current chunk. +pub const MIXNET_SYNC_CHUNK: u32 = 1000; + #[allow(clippy::too_many_arguments)] pub async fn shielded_sync( network: &Network, @@ -665,8 +677,9 @@ pub async fn shielded_sync( start: u32, end: u32, actions_per_sync: u32, + chunk_size: Option, tx_progress: Sender, - rx_cancel: broadcast::Receiver<()>, + mut rx_cancel: broadcast::Receiver<()>, ) -> Result<()> { let activation_height: u32 = network .activation_height(NetworkUpgrade::Sapling) @@ -675,6 +688,52 @@ pub async fn shielded_sync( let start = start.max(activation_height); let end = end.max(activation_height); + let chunk_size = chunk_size.unwrap_or(u32::MAX).max(1); + let mut chunk_start = start; + loop { + let chunk_end = chunk_start.saturating_add(chunk_size - 1).min(end); + shielded_sync_range( + network, + pool, + client, + accounts, + chunk_start, + chunk_end, + actions_per_sync, + tx_progress.clone(), + rx_cancel.resubscribe(), + ) + .await?; + if chunk_end >= end { + break; + } + // A cancel delivered during the finished chunk was consumed by that + // chunk's resubscribed receiver; check ours before starting the next. + match rx_cancel.try_recv() { + Err(broadcast::error::TryRecvError::Empty) + | Err(broadcast::error::TryRecvError::Closed) => {} + _ => { + debug!("Sync cancelled between chunks"); + break; + } + } + chunk_start = chunk_end + 1; + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn shielded_sync_range( + network: &Network, + pool: &SqlitePool, + client: &mut Client, + accounts: &[(u32, bool)], + start: u32, + end: u32, + actions_per_sync: u32, + tx_progress: Sender, + rx_cancel: broadcast::Receiver<()>, +) -> Result<()> { let accounts = accounts.to_vec(); let db_writer_task = { let (s, o, i) = get_tree_state(network, client, start - 1).await?; diff --git a/rust/src/voting.rs b/rust/src/voting.rs index ecde0e875..7d1abd56f 100644 --- a/rust/src/voting.rs +++ b/rust/src/voting.rs @@ -30,7 +30,9 @@ use zcash_voting::prelude::{ VanWitness, VoteCommitStageReporter, VoteConfirmation, VoteSigner, VoteSubmission, VotingDb, VotingHotkey, WitnessData, }; -use zcash_voting::{Network as VotingNetwork, VotingRoundParams}; +use zcash_voting::{ + minimum_voting_eligibility_for_notes, Network as VotingNetwork, VotingRoundParams, +}; use crate::api::coin::Network as WalletNetwork; use crate::warp::hasher::{empty_roots, OrchardHasher}; @@ -54,8 +56,12 @@ pub fn voting_network(network: &WalletNetwork) -> Result { /// /// Migrations are additive and idempotent; all voting state is scoped to /// `wallet_id` (the hex-encoded ZIP-32 seed fingerprint). -pub async fn open_voting_db(pool: SqlitePool, wallet_id: &str) -> Result { - let db = VotingDb::from_pool(pool).await?; +pub async fn open_voting_db( + pool: SqlitePool, + conn: &mut SqliteConnection, + wallet_id: &str, +) -> Result { + let db = VotingDb::from_pool(pool, conn).await?; db.set_wallet_id(wallet_id); Ok(db) } @@ -250,8 +256,10 @@ pub async fn load_round_inputs( h.height ); - // Select unspent, unlocked Ironwood (pool 3) ZEC notes. - let notes = unspent_ironwood_notes(connection, account).await?; + // Select unspent, unlocked Ironwood (pool 3) ZEC notes that existed at + // the snapshot height (mirrors eligible_voting_weight): notes created + // after the snapshot sit past the anchor edge and cannot be rewound. + let notes = unspent_ironwood_notes(connection, account, snapshot_height).await?; ensure!( !notes.is_empty(), "no unspent Ironwood notes available for voting" @@ -317,10 +325,12 @@ pub async fn load_round_inputs( }) } -/// Unspent, unlocked Ironwood (pool 3) ZEC notes as `(note_id, scope)`. +/// Unspent, unlocked Ironwood (pool 3) ZEC notes that existed at or before +/// `snapshot_height`, as `(note_id, scope)`. async fn unspent_ironwood_notes( connection: &mut SqliteConnection, account: u32, + snapshot_height: u32, ) -> Result> { let notes = sqlx::query( "SELECT a.id_note, a.scope @@ -329,9 +339,11 @@ async fn unspent_ironwood_notes( LEFT JOIN assets ast ON a.id_asset = ast.id_asset WHERE b.id_note IS NULL AND a.account = ? AND a.pool = 3 AND a.locked = 0 + AND a.height <= ? AND COALESCE(ast.asset_base, X'0000000000000000000000000000000000000000000000000000000000000000') = X'0000000000000000000000000000000000000000000000000000000000000000'", ) .bind(account) + .bind(snapshot_height) .map(|row: sqlx::sqlite::SqliteRow| { let id: u32 = row.get(0); let scope: Option = row.get(1); @@ -343,6 +355,70 @@ async fn unspent_ironwood_notes( Ok(notes) } +/// Computes the quantized voting weight for the account's currently-unspent +/// Ironwood ZEC notes at `snapshot_height`, using the same canonical bundle +/// planning as the delegation prepare step (sort by value, greedy chunking, +/// per-bundle ballot quantization) — but from the local DB only: no tree +/// state, no witnesses. +/// +/// Notes received after the snapshot are excluded, mirroring the prepare-time +/// rejection (their witness cannot anchor at the snapshot tree). +pub async fn eligible_voting_weight( + connection: &mut SqliteConnection, + account: u32, + snapshot_height: u32, +) -> Result { + let rows = sqlx::query( + "SELECT a.value, a.position, a.cmx, a.nullifier, a.scope, a.diversifier, a.rcm, a.rho + FROM notes a + LEFT JOIN spends b ON a.id_note = b.id_note + LEFT JOIN assets ast ON a.id_asset = ast.id_asset + WHERE b.id_note IS NULL AND a.account = ? + AND a.pool = 3 AND a.locked = 0 + AND a.height <= ? + AND COALESCE(ast.asset_base, X'0000000000000000000000000000000000000000000000000000000000000000') = X'0000000000000000000000000000000000000000000000000000000000000000'", + ) + .bind(account) + .bind(snapshot_height) + .map(|row: sqlx::sqlite::SqliteRow| { + ( + row.get::(0) as u64, // value + row.get::(1), // position + row.get::, _>(2), // cmx + row.get::, _>(3), // nullifier + row.get::, _>(4), // scope + row.get::, _>(5), // diversifier + row.get::, _>(6), // rcm (rseed) + row.get::, _>(7), // rho + ) + }) + .fetch_all(&mut *connection) + .await?; + + let notes: Vec = rows + .into_iter() + .map( + |(value, position, cmx, nullifier, scope, diversifier, rcm, rho)| NoteInfo { + commitment: cmx, + nullifier, + value, + position: position as u64, + diversifier, + rho, + rseed: rcm, + scope: scope.unwrap_or(0) as u32, + ufvk_str: String::new(), + }, + ) + .collect(); + + Ok( + minimum_voting_eligibility_for_notes(¬es, BundlePolicy::default()) + .map(|e| e.eligible_weight) + .unwrap_or(0), + ) +} + async fn unified_full_viewing_key( network: &WalletNetwork, connection: &mut SqliteConnection, @@ -406,7 +482,10 @@ pub async fn prepare_delegation_bundle( bundle_index: u32, bundle_policy: BundlePolicy, ) -> Result { - let db = open_voting_db(pool, wallet_id).await?; + let db = { + let mut conn = pool.acquire().await?; + open_voting_db(pool, &mut conn, wallet_id).await? + }; Ok(zcash_voting::delegate::prepare_delegation_bundle_with_inputs( &db, PrepareDelegationBundleWithInputsParams { @@ -476,7 +555,7 @@ pub async fn prove_and_submit_delegation( pczt_bytes, pir_layout, pir_server_url, - &NoopProgressReporter, + Arc::new(NoopProgressReporter), ) .await } @@ -494,21 +573,49 @@ pub async fn prove_and_submit_delegation_with_progress( pczt_bytes: Vec, pir_layout: zcash_voting::config::PirLayout, pir_server_url: &str, - progress: &dyn DelegationProgressReporter, + progress: Arc, ) -> Result<(DelegationSubmission, String)> { - let db = open_voting_db(pool, wallet_id).await?; + let db = { + let mut conn = pool.acquire().await?; + open_voting_db(pool, &mut conn, wallet_id).await? + }; - let _setup = prepared.setup(&db, progress).await?; + let _setup = prepared.setup(&db, progress.as_ref()).await?; let request = prepared.signing_request(&db).await?; progress.on_progress(DelegationProgress::SigningPayload); let (sig, sighash) = sign_delegation_request(seed, request)?; - let pir_client = zcash_voting::connect_pir_blocking( - pir_layout, - pir_server_url, - Arc::new(zcash_voting::HyperTransport::new()), - )?; - prepared.prove(&db, &pir_client, progress).await?; + // Halo2 delegation proving recurses deeply and overflows the FRB worker + // thread's stack; run it on a dedicated thread with a large stack (the + // same pattern zcashd uses for its proving threads). The PIR client is + // created INSIDE the thread too: its hyper connections are then owned by + // the thread's own runtime, so nothing in the PIR path depends on the + // FRB runtime (which is parked on the thread join) — a cross-runtime + // hyper pool there stalled requests until the 60s transport timeout. + let prove_db = db.clone(); + let prove_prepared = prepared.clone(); + let prove_progress = progress.clone(); + let prove_pir_url = pir_server_url.to_string(); + let join = std::thread::Builder::new() + .stack_size(512 * 1024 * 1024) + .name("delegation-prove".to_string()) + .spawn(move || -> Result { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| anyhow!("failed to create proving runtime: {e}"))?; + let pir_client = rt.block_on(zcash_voting::connect_pir( + pir_layout, + &prove_pir_url, + Arc::new(zcash_voting::HyperTransport::new()), + ))?; + Ok(rt.block_on(prove_prepared.prove( + &prove_db, + &pir_client, + prove_progress.as_ref(), + ))?) + }) + .map_err(|e| anyhow!("failed to spawn delegation proof thread: {e}"))?; + join.join() + .map_err(|e| anyhow!("delegation proof thread panicked: {e:?}"))??; let bundle = prepared .signed_bundle(&db, pczt_bytes, PreparedSigner::signature(sig, sighash)) @@ -530,7 +637,8 @@ pub async fn confirm_delegation( tx_hash: &str, events: &[TxEvent], ) -> Result { - let db = open_voting_db(pool, wallet_id).await?; + let mut conn = pool.acquire().await?; + let db = open_voting_db(pool, &mut conn, wallet_id).await?; Ok(confirm_delegation_submission(&db, round_id, bundle_index, tx_hash, events).await?) } @@ -544,7 +652,8 @@ pub async fn confirm_vote( tx_hash: &str, events: &[TxEvent], ) -> Result { - let db = open_voting_db(pool, wallet_id).await?; + let mut conn = pool.acquire().await?; + let db = open_voting_db(pool, &mut conn, wallet_id).await?; Ok(confirm_vote_submission(&db, round_id, bundle_index, proposal_id, tx_hash, events).await?) } @@ -558,7 +667,8 @@ pub async fn vote_van_witness( bundle_index: u32, vote_node_url: &str, ) -> Result { - let db = open_voting_db(pool, wallet_id).await?; + let mut conn = pool.acquire().await?; + let db = open_voting_db(pool, &mut conn, wallet_id).await?; let anchor_height = zcash_voting::prelude::sync_vote_tree(&db, round_id, vote_node_url).await?; Ok(zcash_voting::prelude::van_witness(&db, round_id, bundle_index, anchor_height).await?) } @@ -599,7 +709,8 @@ pub async fn commit_votes_with_progress( hotkey: &VotingHotkey, stages: &dyn VoteCommitStageReporter, ) -> Result { - let db = open_voting_db(pool, wallet_id).await?; + let mut conn = pool.acquire().await?; + let db = open_voting_db(pool, &mut conn, wallet_id).await?; Ok(zcash_voting::prelude::commit_batch( &db, round_id, @@ -621,7 +732,8 @@ pub async fn vote_payloads( bundle_index: u32, proposal_id: u32, ) -> Result<(VoteSubmission, Vec)> { - let db = open_voting_db(pool, wallet_id).await?; + let mut conn = pool.acquire().await?; + let db = open_voting_db(pool, &mut conn, wallet_id).await?; let committed = CommittedVote::recover(&db, round_id, bundle_index, proposal_id).await?; Ok(( committed.submission(&db).await?, @@ -630,6 +742,12 @@ pub async fn vote_payloads( } /// Reconstructs the chain-ready wire JSON for a committed vote. +/// +/// The first build is persisted under a prop (mirroring the delegation wire) +/// and returned verbatim afterwards, so a resubmission after a crash is +/// byte-identical to the original broadcast — the chain derives the tx hash +/// from those bytes, and an identical resubmission keeps the recorded hash +/// valid. pub async fn vote_wire_json( pool: SqlitePool, wallet_id: &str, @@ -637,10 +755,17 @@ pub async fn vote_wire_json( bundle_index: u32, proposal_id: u32, ) -> Result { - let db = open_voting_db(pool, wallet_id).await?; + let prop_key = format!("voting_round_vote_wire:{round_id}:{bundle_index}:{proposal_id}"); + let mut conn = pool.acquire().await?; + if let Some(saved) = crate::db::get_prop(&mut conn, &prop_key).await? { + return Ok(saved); + } + let db = open_voting_db(pool, &mut conn, wallet_id).await?; let committed = CommittedVote::recover(&db, round_id, bundle_index, proposal_id).await?; let signed = committed.signed_commitment(&db).await?; - Ok(zcash_voting::wire::VoteCommitmentWire::try_from(&signed)?.to_json()?) + let wire = zcash_voting::wire::VoteCommitmentWire::try_from(&signed)?.to_json()?; + crate::db::put_prop(&mut conn, &prop_key, &wire).await?; + Ok(wire) } /// Reconstructs one helper-share payload as helper wire JSON from the @@ -655,9 +780,11 @@ pub async fn share_wire_json( vc_tree_position: Option, submit_at: u64, ) -> Result { - let db = open_voting_db(pool, wallet_id).await?; + let mut conn = pool.acquire().await?; + let db = open_voting_db(pool, &mut conn, wallet_id).await?; let bundle = zcash_voting::recovery::recoverable_commitment_bundle( &db, + &mut conn, round_id, bundle_index, proposal_id, @@ -689,7 +816,8 @@ pub async fn record_vote_execution( vc_tree_position: u64, shares: &[(u32, Vec, u64, bool)], ) -> Result<()> { - let db = open_voting_db(pool, wallet_id).await?; + let mut conn = pool.acquire().await?; + let db = open_voting_db(pool, &mut conn, wallet_id).await?; let committed = CommittedVote::recover(&db, round_id, bundle_index, proposal_id).await?; committed.record_submission(&db, vote_tx_hash).await?; committed.record_vc_position(&db, vc_tree_position).await?; @@ -744,3 +872,195 @@ pub fn load_prepared_bundle( anyhow!("delegation bundle not prepared; run delegation_prepare first") }) } + +#[cfg(test)] +mod tests { + use super::*; + use halo2_proofs::pasta::pallas::Scalar; + use orchard::primitives::redpallas::{Signature, VerificationKey}; + use zcash_protocol::{ + consensus::{BlockHeight, OrchardMode}, + local_consensus::LocalNetwork, + }; + use zip32::fingerprint::SeedFingerprint; + + /// Standard BIP-39 test vector; its 64-byte seed is valid for ZIP-32. + fn test_seed() -> Vec { + Mnemonic::parse( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .unwrap() + .to_seed("") + .to_vec() + } + + fn local_network() -> LocalNetwork { + LocalNetwork { + overwinter: Some(BlockHeight::from_u32(1)), + sapling: Some(BlockHeight::from_u32(1)), + blossom: Some(BlockHeight::from_u32(1)), + heartwood: Some(BlockHeight::from_u32(1)), + canopy: Some(BlockHeight::from_u32(1)), + nu5: Some(BlockHeight::from_u32(1)), + nu6: Some(BlockHeight::from_u32(1)), + nu6_1: Some(BlockHeight::from_u32(1)), + nu6_2: Some(BlockHeight::from_u32(1)), + nu6_3: Some(BlockHeight::from_u32(250)), + nu7: None, + orchard_mode: OrchardMode::Normal, + } + } + + fn signing_request( + fingerprint: [u8; 32], + account_index: u32, + alpha: [u8; 32], + ) -> DelegationSigningRequest { + DelegationSigningRequest { + account_index, + network: VotingNetwork::Mainnet, + seed_fingerprint: fingerprint, + sighash: [7u8; 32], + alpha, + } + } + + /// Rebuilds the signing key from the request the same way + /// `sign_delegation_request` does, and verifies the returned signature. + fn assert_valid_signature( + request: &DelegationSigningRequest, + seed: &[u8], + sig_bytes: &[u8; 64], + ) { + let account = AccountId::try_from(request.account_index).unwrap(); + let usk = UnifiedSpendingKey::from_seed(&request.network, seed, account).unwrap(); + let sk = *usk.orchard(); + let ask = orchard::keys::SpendAuthorizingKey::from(&sk); + let alpha = Scalar::from_repr(request.alpha).unwrap(); + let vk = VerificationKey::from(&ask.randomize(&alpha)); + let sig = Signature::from(*sig_bytes); + vk.verify(&request.sighash, &sig).unwrap(); + } + + #[test] + fn voting_network_maps_each_wallet_network_to_fork_network() { + assert_eq!( + voting_network(&WalletNetwork::Main).unwrap(), + VotingNetwork::Mainnet + ); + assert_eq!( + voting_network(&WalletNetwork::Test).unwrap(), + VotingNetwork::Testnet + ); + assert_eq!( + voting_network(&WalletNetwork::Regtest(local_network())).unwrap(), + VotingNetwork::Regtest + ); + } + + #[test] + fn voting_network_rejects_zsa_regtest() { + let err = voting_network(&WalletNetwork::ZsaRegtest(local_network())).unwrap_err(); + assert_eq!(err.to_string(), "voting is not supported on the ZSA network"); + } + + #[test] + fn sign_delegation_request_rejects_invalid_seed_length() { + let request = signing_request([0u8; 32], 0, [0u8; 32]); + let err = sign_delegation_request(&[0xAAu8; 16], request).unwrap_err(); + assert_eq!( + err.to_string(), + "wallet seed length is not valid for ZIP-32" + ); + } + + #[test] + fn sign_delegation_request_rejects_seed_fingerprint_mismatch() { + let seed = test_seed(); + let mut fingerprint = SeedFingerprint::from_seed(&seed).unwrap().to_bytes(); + fingerprint[0] ^= 0x01; + let request = signing_request(fingerprint, 0, [0u8; 32]); + let err = sign_delegation_request(&seed, request).unwrap_err(); + assert_eq!( + err.to_string(), + "wallet seed fingerprint does not match delegation signing request" + ); + } + + #[test] + fn sign_delegation_request_rejects_account_index_at_or_above_2_to_31() { + let seed = test_seed(); + let fingerprint = SeedFingerprint::from_seed(&seed).unwrap().to_bytes(); + let request = signing_request(fingerprint, 1 << 31, [0u8; 32]); + let err = sign_delegation_request(&seed, request).unwrap_err(); + assert_eq!(err.to_string(), "invalid account_index 2147483648"); + + // One below the boundary passes the account-index check; the request + // then fails on the intentionally non-canonical alpha, pinning the + // exact boundary. + let request = signing_request(fingerprint, (1 << 31) - 1, [0xFFu8; 32]); + let err = sign_delegation_request(&seed, request).unwrap_err(); + assert_eq!( + err.to_string(), + "delegation alpha is not a valid Pallas scalar" + ); + } + + #[test] + fn sign_delegation_request_rejects_noncanonical_alpha() { + let seed = test_seed(); + let fingerprint = SeedFingerprint::from_seed(&seed).unwrap().to_bytes(); + let request = signing_request(fingerprint, 0, [0xFFu8; 32]); + let err = sign_delegation_request(&seed, request).unwrap_err(); + assert_eq!( + err.to_string(), + "delegation alpha is not a valid Pallas scalar" + ); + } + + #[test] + fn sign_delegation_request_signs_with_zero_alpha_and_returns_sighash() { + let seed = test_seed(); + let fingerprint = SeedFingerprint::from_seed(&seed).unwrap().to_bytes(); + let request = signing_request(fingerprint, 0, [0u8; 32]); + let (sig_bytes, sighash) = sign_delegation_request(&seed, request).unwrap(); + assert_eq!(sighash, [7u8; 32]); + assert_valid_signature(&request, &seed, &sig_bytes); + } + + #[test] + fn sign_delegation_request_signs_with_nonzero_alpha_under_randomized_key() { + let seed = test_seed(); + let fingerprint = SeedFingerprint::from_seed(&seed).unwrap().to_bytes(); + let mut alpha = [0u8; 32]; + alpha[0] = 7; + let request = signing_request(fingerprint, 0, alpha); + let (sig_bytes, _) = sign_delegation_request(&seed, request).unwrap(); + assert_valid_signature(&request, &seed, &sig_bytes); + + // Negative control: the *unrandomized* ak must not verify, proving + // the alpha randomizer actually enters the signing key. + let account = AccountId::try_from(request.account_index).unwrap(); + let usk = UnifiedSpendingKey::from_seed(&request.network, &seed, account).unwrap(); + let ask = orchard::keys::SpendAuthorizingKey::from(&*usk.orchard()); + let zero = Scalar::from_repr([0u8; 32]).unwrap(); + let vk_ak = VerificationKey::from(&ask.randomize(&zero)); + let sig = Signature::from(sig_bytes); + assert!(vk_ak.verify(&request.sighash, &sig).is_err()); + } + + #[test] + fn bundle_cache_key_formats_wallet_round_bundle() { + assert_eq!( + bundle_cache_key("wallet-abc", "round-42", 0), + "wallet-abc:round-42:0" + ); + assert_eq!(bundle_cache_key("w", "r", u32::MAX), "w:r:4294967295"); + assert_eq!(bundle_cache_key("", "", 0), "::0"); + assert_ne!( + bundle_cache_key("a", "r", 1), + bundle_cache_key("b", "r", 1), + "different wallet ids must not share a cache key" + ); + } +} diff --git a/rust/tests/voting_recovery_reconcile.rs b/rust/tests/voting_recovery_reconcile.rs new file mode 100644 index 000000000..1b4a5e778 --- /dev/null +++ b/rust/tests/voting_recovery_reconcile.rs @@ -0,0 +1,280 @@ +//! Recovery-reconciliation tests for the voting fork: recording chain +//! confirmations whose evidence came from a commitment-tree scan instead of +//! tx events (no tx hash), and locating leaves in the commitment tree. + +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use vote_commitment_tree::MemoryTreeServer; +use zcash_voting::prelude::{ + delegation_van_commitment, find_leaf_position_with_api, record_delegation_confirmation_from_tree, + record_van_position, record_vote_confirmation_from_tree, DelegationPhase, Network, RoundParams, + VotePhase, VotingDb, +}; + +const ROUND_ID: &str = "1111111111111111111111111111111111111111111111111111111111111111"; +const WALLET_ID: &str = "wallet-reconcile"; + +fn round_params() -> RoundParams { + RoundParams { + vote_round_id: ROUND_ID.to_string(), + snapshot_height: 100, + ea_pk: vec![0xEA_u8; 32], + nc_root: vec![0xAA_u8; 32], + nullifier_imt_root: vec![0xBB_u8; 32], + } +} + +async fn test_db() -> (sqlx::SqlitePool, VotingDb) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(SqliteConnectOptions::new().in_memory(true)) + .await + .unwrap(); + let mut conn = pool.acquire().await.unwrap(); + let db = VotingDb::from_pool(pool.clone(), &mut conn).await.unwrap(); + drop(conn); + db.set_wallet_id(WALLET_ID); + db.create_round(Network::Testnet, &round_params(), None) + .await + .unwrap(); + (pool, db) +} + +async fn insert_bundle(pool: &sqlx::SqlitePool, bundle_index: u32, gov_comm: Option>) { + sqlx::query( + "INSERT INTO voting_bundles (round_id, wallet_id, bundle_index, address_index, total_note_value, gov_comm) + VALUES (?, ?, ?, 0, 100, ?)", + ) + .bind(ROUND_ID) + .bind(WALLET_ID) + .bind(bundle_index as i64) + .bind(gov_comm) + .execute(pool) + .await + .unwrap(); +} + +async fn insert_vote( + pool: &sqlx::SqlitePool, + bundle_index: u32, + proposal_id: u32, + with_recovery: bool, +) { + sqlx::query( + "INSERT INTO voting_votes (round_id, wallet_id, bundle_index, proposal_id, choice, commitment, created_at, commitment_bundle_json) + VALUES (?, ?, ?, ?, 2, NULL, 1, ?)", + ) + .bind(ROUND_ID) + .bind(WALLET_ID) + .bind(bundle_index as i64) + .bind(proposal_id as i64) + .bind(if with_recovery { Some(recovery_json()) } else { None }) + .execute(pool) + .await + .unwrap(); +} + +/// Mirrors the wire format produced by the fork's `serialize_recovery` for a +/// committed vote; the recovery JSON round-trips through `parse_recovery`. +fn recovery_json() -> String { + serde_json::to_string(&serde_json::json!({ + "format": "zcash_voting_vote_recovery_v1", + "vote_round_id": ROUND_ID, + "bundle_index": 0, + "proposal_id": 1, + "vote_decision": 2, + "anchor_height": 100, + "vc_tree_position": 0, + "single_share": false, + "num_options": 3, + "van_nullifier": vec![0x31_u8; 32], + "vote_authority_note_new": vec![0x32_u8; 32], + "vote_commitment": vec![0x33_u8; 32], + "proof": vec![0x34_u8; 8], + "shares_hash": vec![0x35_u8; 32], + "r_vpk": vec![0x36_u8; 32], + "alpha_v": vec![0x37_u8; 32], + "vote_auth_sig": vec![0x38_u8; 64], + "encrypted_shares": [], + "share_blinds": [], + "share_comms": [], + })) + .unwrap() +} + +fn fp(x: u64) -> pasta_curves::Fp { + pasta_curves::Fp::from(x) +} + +#[tokio::test] +async fn tree_confirmation_records_vote_without_tx_hash() { + let (pool, db) = test_db().await; + insert_bundle(&pool, 0, None).await; + insert_vote(&pool, 0, 1, true).await; + + let result = record_vote_confirmation_from_tree(&db, ROUND_ID, 0, 1, 42, Some(41)) + .await + .unwrap(); + assert_eq!(result.vc_tree_position, 42); + assert_eq!(result.van_leaf_position, Some(41)); + + let mut conn = pool.acquire().await.unwrap(); + // Phase derivation reports Confirmed without a tx hash; the bundle's VAN + // pointer advanced to the vote's VAN output position. + assert_eq!( + db.vote_phase(&mut conn, ROUND_ID, 0, 1).await.unwrap(), + VotePhase::Confirmed + ); + assert_eq!( + db.delegation_phase(&mut conn, ROUND_ID, 0).await.unwrap(), + DelegationPhase::Confirmed + ); +} + +#[tokio::test] +async fn tree_confirmation_rejects_missing_recovery_bundle() { + let (pool, db) = test_db().await; + insert_bundle(&pool, 0, None).await; + insert_vote(&pool, 0, 1, false).await; + + let err = record_vote_confirmation_from_tree(&db, ROUND_ID, 0, 1, 42, None) + .await + .unwrap_err(); + assert!(matches!(err, zcash_voting::VotingError::InvalidInput { .. }), "got {err:?}"); +} + +#[tokio::test] +async fn tree_confirmation_replay_is_idempotent_and_conflict_checked() { + let (pool, db) = test_db().await; + insert_bundle(&pool, 0, None).await; + insert_vote(&pool, 0, 1, true).await; + + record_vote_confirmation_from_tree(&db, ROUND_ID, 0, 1, 42, Some(41)) + .await + .unwrap(); + // Replay with the same evidence is accepted. + record_vote_confirmation_from_tree(&db, ROUND_ID, 0, 1, 42, Some(41)) + .await + .unwrap(); + // A different VC position conflicts with the recorded one. + let err = record_vote_confirmation_from_tree(&db, ROUND_ID, 0, 1, 43, Some(42)) + .await + .unwrap_err(); + assert!(matches!(err, zcash_voting::VotingError::InvalidInput { .. }), "got {err:?}"); +} + +#[tokio::test] +async fn delegation_tree_confirmation_stores_van_position_and_never_rewinds() { + let (pool, db) = test_db().await; + insert_bundle(&pool, 0, None).await; + + record_delegation_confirmation_from_tree(&db, ROUND_ID, 0, 7) + .await + .unwrap(); + let mut conn = pool.acquire().await.unwrap(); + assert_eq!( + db.delegation_phase(&mut conn, ROUND_ID, 0).await.unwrap(), + DelegationPhase::Confirmed + ); + + // A later vote confirmation advanced the pointer past the delegation + // position; recovery must not rewind it. + drop(conn); + record_van_position(&db, ROUND_ID, 0, 9).await.unwrap(); + record_delegation_confirmation_from_tree(&db, ROUND_ID, 0, 7) + .await + .unwrap(); + let position: Option = sqlx::query_scalar( + "SELECT van_leaf_position FROM voting_bundles + WHERE round_id = ? AND wallet_id = ? AND bundle_index = 0", + ) + .bind(ROUND_ID) + .bind(WALLET_ID) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(position, Some(9)); +} + +#[tokio::test] +async fn delegation_van_commitment_reads_persisted_gov_comm() { + use ff::PrimeField as _; + let (pool, db) = test_db().await; + let gov_comm = fp(77); + insert_bundle(&pool, 0, Some(gov_comm.to_repr().to_vec())).await; + + let recovered = delegation_van_commitment(&db, ROUND_ID, 0) + .await + .unwrap() + .expect("bundle gov_comm must load"); + assert_eq!(recovered, gov_comm); +} + +#[tokio::test] +async fn find_leaf_position_locates_leaf_in_server_pages() { + let mut server = MemoryTreeServer::empty(); + // One delegation leaf, then a cast-vote pair (VAN output, then vote + // commitment) — mirroring the chain's append order. + server.append(fp(10)).unwrap(); + server.checkpoint(1).unwrap(); + server.append_two(fp(20), fp(21)).unwrap(); + server.checkpoint(2).unwrap(); + + assert_eq!( + find_leaf_position_with_api(&server, fp(10), 4).await.unwrap(), + Some(0) + ); + assert_eq!( + find_leaf_position_with_api(&server, fp(21), 4).await.unwrap(), + Some(2) + ); + assert_eq!( + find_leaf_position_with_api(&server, fp(99), 4).await.unwrap(), + None + ); +} + +#[tokio::test] +async fn find_leaf_position_returns_none_on_empty_tree() { + let server = MemoryTreeServer::empty(); + assert_eq!( + find_leaf_position_with_api(&server, fp(1), 4).await.unwrap(), + None + ); +} + +#[tokio::test] +async fn migration_v13_upgrade_adds_confirmed_without_hash_column() { + let (pool, _db) = test_db().await; + // Simulate a v13 wallet DB: drop the v14 column and rewind the version. + sqlx::query("ALTER TABLE voting_votes DROP COLUMN confirmed_without_hash") + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE voting_schema_version SET version = 13") + .execute(&pool) + .await + .unwrap(); + + let mut conn = pool.acquire().await.unwrap(); + let db = VotingDb::from_pool(pool.clone(), &mut conn).await.unwrap(); + drop(conn); + db.set_wallet_id(WALLET_ID); + + // The upgrade must restore the column and phase derivation end to end. + insert_bundle(&pool, 0, None).await; + insert_vote(&pool, 0, 1, true).await; + record_vote_confirmation_from_tree(&db, ROUND_ID, 0, 1, 42, Some(41)) + .await + .unwrap(); + let mut conn = pool.acquire().await.unwrap(); + assert_eq!( + db.vote_phase(&mut conn, ROUND_ID, 0, 1).await.unwrap(), + VotePhase::Confirmed + ); + drop(conn); + let version: i64 = sqlx::query_scalar("SELECT MAX(version) FROM voting_schema_version") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(version, 14); +} diff --git a/test/services/votechain_backoff_test.dart b/test/services/votechain_backoff_test.dart new file mode 100644 index 000000000..7dd826352 --- /dev/null +++ b/test/services/votechain_backoff_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zkool/services/votechain_backoff.dart'; + +void main() { + test('voteChainRetryDelay follows the 30/60/120 schedule', () { + expect(voteChainRetryDelay(1), const Duration(seconds: 30)); + expect(voteChainRetryDelay(2), const Duration(seconds: 60)); + expect(voteChainRetryDelay(3), const Duration(seconds: 120)); + expect(voteChainRetryDelay(10), const Duration(seconds: 120)); + }); + + test('max auto retries is bounded', () { + expect(voteChainMaxAutoRetries, 3); + }); +} diff --git a/test/services/votechain_classify_test.dart b/test/services/votechain_classify_test.dart new file mode 100644 index 000000000..bca07c157 --- /dev/null +++ b/test/services/votechain_classify_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zkool/services/votechain_classify.dart'; + +void main() { + group('classifyVoteChainRejection', () { + test('detects duplicate nullifier rejections', () { + expect( + classifyVoteChainRejection('nullifier already spent'), + ChainRejectionKind.duplicateNullifier, + ); + expect( + classifyVoteChainRejection( + '{"code":1,"log":"nullifier already spent"}', + ), + ChainRejectionKind.duplicateNullifier, + ); + expect( + classifyVoteChainRejection( + 'failed to execute message; nullifier spend check failed', + ), + ChainRejectionKind.duplicateNullifier, + ); + }); + + test('classifies other rejections as permanent', () { + expect( + classifyVoteChainRejection('vote round is not active'), + ChainRejectionKind.other, + ); + expect( + classifyVoteChainRejection('{"code":2,"log":"bad proof"}'), + ChainRejectionKind.other, + ); + expect(classifyVoteChainRejection(''), ChainRejectionKind.other); + }); + }); + + group('txHashFromVoteChainBody', () { + test('extracts tx_hash from the JSON envelope', () { + expect( + txHashFromVoteChainBody( + '{"tx_hash":"ABCDEF123456","code":0,"log":""}', + ), + 'ABCDEF123456', + ); + }); + + test('extracts tx_hash from the 502 unknown-outcome message', () { + expect( + txHashFromVoteChainBody( + 'broadcast outcome unknown after retries; tx_hash=ABCDEF123456', + ), + 'ABCDEF123456', + ); + }); + + test('returns null when no hash is present', () { + expect(txHashFromVoteChainBody('{}'), isNull); + expect(txHashFromVoteChainBody('not json at all'), isNull); + expect(txHashFromVoteChainBody(''), isNull); + }); + }); +} diff --git a/test/services/votechain_confirmation_test.dart b/test/services/votechain_confirmation_test.dart new file mode 100644 index 000000000..495c45640 --- /dev/null +++ b/test/services/votechain_confirmation_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zkool/services/votechain_confirmation.dart'; + +void main() { + group('parseVoteChainTxConfirmation', () { + test('parses a valid body with events', () { + final conf = parseVoteChainTxConfirmation( + '{"height": 12345, "code": 0, "events": [{"type": "delegate-vote"}]}', + ); + expect(conf, isNotNull); + expect(conf!.height, 12345); + expect(conf.eventsJson, contains('delegate-vote')); + }); + + test('height 0 means not yet included', () { + expect( + parseVoteChainTxConfirmation('{"height": 0, "events": []}'), + isNull, + ); + }); + + test('missing height is not a confirmation', () { + expect( + parseVoteChainTxConfirmation('{"code": 0, "events": []}'), + isNull, + ); + }); + + test('null height is not a confirmation', () { + expect( + parseVoteChainTxConfirmation('{"height": null, "events": []}'), + isNull, + ); + }); + + test('negative height is not a confirmation', () { + expect( + parseVoteChainTxConfirmation('{"height": -5, "events": []}'), + isNull, + ); + }); + + test('numeric height is accepted', () { + final conf = + parseVoteChainTxConfirmation('{"height": 12.0, "events": []}'); + expect(conf, isNotNull); + expect(conf!.height, 12); + }); + + test('string height is accepted (the chain reports heights as strings)', () { + final conf = parseVoteChainTxConfirmation( + '{"height": "7163319", "events": [{"type": "delegate_vote"}]}', + ); + expect(conf, isNotNull); + expect(conf!.height, 7163319); + expect(conf.eventsJson, contains('delegate_vote')); + }); + + test('non-numeric string height is not a confirmation', () { + expect( + parseVoteChainTxConfirmation('{"height": "abc", "events": []}'), + isNull, + ); + }); + + test('malformed JSON is not a confirmation', () { + expect(parseVoteChainTxConfirmation('not json'), isNull); + }); + + test('non-map body is not a confirmation', () { + expect(parseVoteChainTxConfirmation('[1, 2, 3]'), isNull); + }); + + test('missing events defaults to an empty array', () { + final conf = + parseVoteChainTxConfirmation('{"height": 7, "code": 0}'); + expect(conf, isNotNull); + expect(conf!.eventsJson, '[]'); + }); + }); + + group('parseVoteChainRejection', () { + test('extracts code and log from the envelope', () { + final rejection = parseVoteChainRejection( + '{"tx_hash":"","code":3,"log":"nullifier already spent"}', + ); + expect(rejection.code, 3); + expect(rejection.log, 'nullifier already spent'); + }); + + test('falls back to the raw body for non-envelope bodies', () { + final rejection = parseVoteChainRejection('plain text'); + expect(rejection.code, -1); + expect(rejection.log, 'plain text'); + }); + }); +} diff --git a/test/services/votechain_failover_test.dart b/test/services/votechain_failover_test.dart new file mode 100644 index 000000000..e2005adc0 --- /dev/null +++ b/test/services/votechain_failover_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zkool/services/votechain_failover.dart'; +import 'package:zkool/src/rust/api/voting.dart'; + +VotingChainResponse response(int status, {String body = '{}', int? retryAfter}) { + return VotingChainResponse( + statusCode: status, + body: body, + retryAfterSecs: retryAfter == null ? null : BigInt.from(retryAfter), + ); +} + +void main() { + test('candidate ordering: caller list first, then config, deduplicated', () { + final failover = VoteChainFailover( + allServers: const ['a', 'b', 'c'], + delay: (_) async {}, + ); + expect( + failover.orderedCandidates(['c', 'd'], 'r1'), + ['c', 'd', 'a', 'b'], + ); + }); + + test('remembers the last working URL per round and tries it first', () async { + final calls = []; + final failover = VoteChainFailover( + allServers: const ['a', 'b'], + delay: (_) async {}, + ); + await failover.run( + baseUrls: ['a', 'b'], + roundId: 'r1', + call: (url) async { + calls.add(url); + if (url == 'a') throw Exception('down'); + return response(200); + }, + ); + expect(calls, ['a', 'b']); + + final again = await failover.run( + baseUrls: ['a', 'b'], + roundId: 'r1', + call: (url) async { + calls.add(url); + return response(200); + }, + ); + expect(again.statusCode, 200); + // The remembered URL (b) is tried first and answers immediately. + expect(calls, ['a', 'b', 'b']); + }); + + test('4xx is a final answer', () async { + final failover = VoteChainFailover(delay: (_) async {}); + final res = await failover.run( + baseUrls: ['a', 'b'], + roundId: 'r1', + call: (url) async => url == 'a' ? response(422, body: 'rejected') : fail('b must not be tried'), + ); + expect(res.statusCode, 422); + }); + + test('404 is a final answer (not confirmed yet)', () async { + final failover = VoteChainFailover(delay: (_) async {}); + final res = await failover.run( + baseUrls: ['a'], + roundId: 'r1', + call: (_) async => response(404, body: '{"error":"tx not found"}'), + ); + expect(res.statusCode, 404); + }); + + test('5xx rotates to the next server', () async { + final failover = VoteChainFailover(delay: (_) async {}); + final res = await failover.run( + baseUrls: ['a', 'b'], + roundId: 'r1', + call: (url) async => url == 'a' ? response(500) : response(200), + ); + expect(res.statusCode, 200); + }); + + test('503 honors Retry-After and retries the same URL once', () async { + final delays = []; + var calls = 0; + final failover = VoteChainFailover(delay: (d) async => delays.add(d)); + final res = await failover.run( + baseUrls: ['a'], + roundId: 'r1', + call: (_) async { + calls++; + return calls == 1 + ? response(503, retryAfter: 5) + : response(200); + }, + ); + expect(res.statusCode, 200); + expect(delays, [const Duration(seconds: 5)]); + }); + + test('502 with a tx hash is returned as an answer', () async { + final failover = VoteChainFailover(delay: (_) async {}); + final res = await failover.run( + baseUrls: ['a'], + roundId: 'r1', + call: (_) async => + response(502, body: 'broadcast outcome unknown after retries; tx_hash=ABCDEF'), + ); + expect(res.statusCode, 502); + expect(res.body, contains('ABCDEF')); + }); + + test('502 without a hash rotates to the next server', () async { + final failover = VoteChainFailover(delay: (_) async {}); + final res = await failover.run( + baseUrls: ['a', 'b'], + roundId: 'r1', + call: (url) async => url == 'a' ? response(502, body: 'boom') : response(200), + ); + expect(res.statusCode, 200); + }); + + test('all candidates failing raises TransientVoteChainException', () async { + final failover = VoteChainFailover(delay: (_) async {}); + expect( + () => failover.run( + baseUrls: ['a', 'b'], + roundId: 'r1', + call: (_) async => throw Exception('down'), + ), + throwsA(isA()), + ); + }); +} diff --git a/tools/test_plugin/main.rhai b/tools/test_plugin/main.rhai index 9f332da37..2536b1d60 100644 --- a/tools/test_plugin/main.rhai +++ b/tools/test_plugin/main.rhai @@ -12,9 +12,9 @@ fn get_prefixes() { return ["444b3030"]; // "DK00" in hex } -fn process_memo() { - let from_id = memo::read_u8(4); - let data_len = memo::read_u64_le(5); +fn process_memo(memo) { + let from_id = memo.read_u8(4); + let data_len = memo.read_u64_le(5); let headers = ["Field", "Value"]; let rows = [ diff --git a/version.txt b/version.txt index 9cd1a39f6..7874ad0b7 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -6.27.0 +6.28.0-rc.10