diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a85d47..2d6f7d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,3 +105,6 @@ jobs: - name: Check at MSRV run: cargo check --all-targets + + # (The former dedicated `ironwood` clippy job is gone: ironwood is now compiled unconditionally, + # so the `test` job's `cargo clippy --all-targets -- -D warnings` above already covers it.) diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index 49a5d2d..45de6eb 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -19,6 +19,11 @@ name: Regtest E2E # notes and times multi-minute sends - so it is gated separately (ZECD_REGTEST_STRESS) and runs # ONLY on a monthly cron (1st of the month) or a manual dispatch with the `stress` box ticked. # It compiles on every run but skips in seconds otherwise. +# +# A separate `regtest-ironwood` job (schedule + manual dispatch, plus the +# ironwood-on-transparent-fixes branch) builds zecd `--features ironwood` - against the official +# zcash/librustzcash ironwood RC (no `nu6.3` cfg needed any more) - and runs the NU6.3 receive+send +# e2e against the ironwood toolchain images - see that job's header. on: workflow_dispatch: @@ -35,6 +40,18 @@ on: description: "Notes to build in the stress test (blank = default 256)" required: false default: "" + zebra-ironwood-image: + description: "Ironwood tier: NU6.3-capable zebrad image (default zfnd/zebra:6.0.0); blank = the job default" + required: false + default: "" + lwd-ironwood-image: + description: "Ironwood tier: V6-aware lightwalletd image; blank = the job default" + required: false + default: "" + devtool-ironwood-image: + description: "Ironwood tier: regtest-aware zcash-devtool image; blank = the job default" + required: false + default: "" schedule: # Weekly canary against zfnd/zebra:latest (Mondays 06:17 UTC; an off-peak odd minute). - cron: "17 6 * * 1" @@ -200,11 +217,14 @@ jobs: ZECD_STDERR: "1" RUST_LOG: "zecd=info,zecd::wallet::actor=debug,zecd::chain=debug" # The extended tier (reorg, multiwallet, stop/restore, watch-only) runs on the weekly "big - # run" and on manual dispatches; PR/push runs let those tests skip in seconds. + # run" and on manual dispatches; all PR/push runs (incl. the ironwood dev branch) let those + # tests skip in seconds. The dev branch deliberately does NOT force extended: its reorg + # test is timing/finalization-boundary sensitive and flakes, which would redden the PR for + # reasons unrelated to the ironwood work and obscure that signal. (It still runs weekly.) ZECD_REGTEST_EXTENDED: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '1' || '' }} # The heavy note-scaling stress test runs ONLY on the monthly cron (the 1st-of-month # schedule) and on a manual dispatch that ticks `stress` - never on push/PR, and not on - # the weekly cron. Its note target is `stress-note-count` on dispatch (blank → default 256). + # the weekly cron. Its note target is `stress-note-count` on dispatch (blank -> default 256). ZECD_REGTEST_STRESS: ${{ (github.event.schedule == '33 7 1 * *' || (github.event_name == 'workflow_dispatch' && inputs.stress)) && '1' || '' }} ZECD_STRESS_NOTE_COUNT: ${{ inputs.stress-note-count }} # ZEBRAD_BIN / LIGHTWALLETD_BIN are exported by the provisioning step above. @@ -221,3 +241,107 @@ jobs: df -h / "$RUNNER_TEMP" || true echo "== zebrad log (last 200 lines) ==" tail -n 200 "$RUNNER_TEMP/zebrad.log" 2>/dev/null || echo "(no zebrad log was written)" + + # Ironwood (NU6.3) tier. Builds zecd with the `ironwood` feature (no cfg - the librustzcash pin, + # zcash/librustzcash `dw/ironwood-scan-model`, exposes the ironwood wallet APIs unconditionally), + # and runs the gated regtest_ironwood receive e2e against the NU6.3 toolchain: the OFFICIAL zebra + # ironwood release (`zfnd/zebra:6.0.0`) plus a still-dev V6-aware lightwalletd and a regtest-aware + # zcash-devtool funder. It funds zecd post-NU6.3 (auto-routed to an ironwood output) and asserts + # zecd labels the received note `pool == "ironwood"`. + # + # zebra is now an official image, but lwd + devtool are still DEV images (emersonian/*) that may + # move or vanish, so this job runs ONLY on the weekly schedule and on manual dispatch - never on + # other branches' PRs, so a missing image can't redden PR CI. Bump the image refs in `env` below + # (or pass them as dispatch inputs) as the ironwood stack is rebuilt; keep the NU6.3 height (8) in + # lockstep with the harness' NU6_3_ACTIVATION_HEIGHT, zecd's network::regtest(), and devtool's + # regtest_params(). + regtest-ironwood: + # schedule + manual dispatch, plus the ironwood integration line so ironwood-work PRs auto-run + # the NU6.3 e2e without a manual dispatch. `base_ref` matches any PR **into** `ironwood` (its + # merge target); `ref` matches a direct push to `ironwood`. Never on unrelated branches' PRs - + # the emersonian/* lwd/devtool images are dev artifacts and must not gate anyone else's CI. Drop + # this keying (run it everywhere) once ironwood merges to the mainline and those images ship. + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.base_ref == 'ironwood' || github.ref == 'refs/heads/ironwood' }} + runs-on: ubuntu-latest + name: regtest (ironwood / NU6.3) + env: + # zebrad is now the OFFICIAL ironwood-capable release on Docker Hub (`zfnd/zebra:6.0.0`), + # which is NU6.3-capable and accepts the `"NU6.3"` regtest activation-height key - so the dev + # `emersonian/zcash-zakura` image is retired. lwd + devtool stay pinned to dev images by + # @sha256 digest (their tags are mutable), both rebuilt against the official zebra RC's V6 wire + # format: lwd = `emersonian/zcash-lwd-ironwood:360a37d-zr8-nu63` (V6 parser for zebra 6.0.0); + # devtool = `emersonian/zcash-devtool:cac96846` (== ironwood-valar tip), built with + # `regtest_support` so its regtest `init` takes `--activation-heights`; sends+receives ironwood. + # A dispatch input can override any of them (e.g. with a `repo:tag`) when the stack is rebuilt; + # bump these as the images are rebuilt. + ZEBRAD_IRONWOOD_IMAGE: ${{ inputs.zebra-ironwood-image || 'zfnd/zebra:6.0.0' }} + LWD_IRONWOOD_IMAGE: ${{ inputs.lwd-ironwood-image || 'emersonian/zcash-lwd-ironwood@sha256:5f63b64ed788330ad8fe859fbbb334ceea844cf8a376318e7e25fbd3351b2f9f' }} + DEVTOOL_IRONWOOD_IMAGE: ${{ inputs.devtool-ironwood-image || 'emersonian/zcash-devtool@sha256:fd95d8b432f47d3eec6e5f1eca0bdfbb8daa094c28742d230bec3ac47957bd6c' }} + steps: + - uses: actions/checkout@v4 + + - name: Install protobuf compiler + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + regtest-harness + # Separate cache key: this build carries the ironwood feature + nu6.3 cfg. + key: ironwood + + # zecd compiles the ironwood scan/balance/pool/send paths unconditionally now (no cargo + # feature) - this is a plain release build. NU6.3 activation on the regtest chain is turned on + # for the zecd process below via the `ZECD_REGTEST_NU63_HEIGHT` env var (see the e2e step). + - name: Build zecd (release) + run: cargo build --release --bin zecd + + # Extract zebrad + lightwalletd + the devtool funder from the ironwood toolchain images. + - name: Provision ironwood zebrad + lightwalletd + devtool from images + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bin" + extract() { + img="$1"; name="$2"; out="$RUNNER_TEMP/bin/$name" + docker pull -q "$img" + cid=$(docker create "$img") + rel=$(docker export "$cid" | tar -tf - | grep -m1 -E "(^|/)${name}\$" || true) + if [ -z "$rel" ]; then echo "::error::could not locate $name in $img"; docker rm "$cid" >/dev/null; exit 1; fi + docker cp "$cid:/$rel" "$out" + docker rm "$cid" >/dev/null + chmod +x "$out" + echo "$name -> $out"; file -b "$out" || true + } + extract "$ZEBRAD_IRONWOOD_IMAGE" zebrad + extract "$LWD_IRONWOOD_IMAGE" lightwalletd + extract "$DEVTOOL_IRONWOOD_IMAGE" zcash-devtool + echo "ZEBRAD_BIN=$RUNNER_TEMP/bin/zebrad" >> "$GITHUB_ENV" + echo "LIGHTWALLETD_BIN=$RUNNER_TEMP/bin/lightwalletd" >> "$GITHUB_ENV" + echo "DEVTOOL_BIN=$RUNNER_TEMP/bin/zcash-devtool" >> "$GITHUB_ENV" + + - name: Run ironwood (NU6.3) receive e2e + env: + ZECD_BIN: ${{ github.workspace }}/target/release/zecd + ZECD_REGTEST_IRONWOOD: "1" + # Activate NU6.3 at height 8 on the regtest chain for the zecd process (matches the zebra + # the ironwood harness configures). Ironwood is always compiled; this env var is what turns + # its activation on for regtest. The standard regtest tier leaves it unset (no NU6.3). + ZECD_REGTEST_NU63_HEIGHT: "8" + ZEBRAD_STDERR: ${{ runner.temp }}/zebrad.log + # Stream zecd's own logs into the run output (info level) so a failure is diagnosable + # without the debug flood that the receive/send bring-up needed. + ZECD_STDERR: "1" + RUST_LOG: "zecd=info" + # ZEBRAD_BIN / LIGHTWALLETD_BIN / DEVTOOL_BIN are exported by the provisioning step. + run: cargo test --locked --manifest-path regtest-harness/Cargo.toml --test regtest_ironwood --no-fail-fast -- --nocapture --test-threads=1 + + - name: Dump diagnostics on failure + if: failure() + run: | + echo "== disk ==" + df -h / "$RUNNER_TEMP" || true + echo "== zebrad log (last 200 lines) ==" + tail -n 200 "$RUNNER_TEMP/zebrad.log" 2>/dev/null || echo "(no zebrad log was written)" diff --git a/CHANGELOG.md b/CHANGELOG.md index c9ae64f..416d71c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to zecd are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [0.5.0-rc3] - 2026-07-13 + +### Changed +- The librustzcash crates are now consumed from crates.io - the wallet crates as release candidates (`zcash_client_backend 0.24.0-rc.1`, `zcash_client_sqlite 0.22.0-rc.1`, `pczt 0.8.0-rc.1`, `zip321 0.9.0-rc.1`) and the rest as finals - instead of a pinned git revision, so a build no longer pulls any git dependency. + +## [0.5.0-rc2] - 2026-07-12 + +### Changed +- librustzcash is repointed from the interim zecrocks fork to the upstream zcash/librustzcash ironwood line; its fixes have landed upstream, so the fork is no longer needed. +- Rebased onto 0.4.3, so the ironwood line now carries every 0.4.x maintenance fix through 0.4.3 (Sapling-output fused-path routing, the `/readyz` "synced" default with per-wallet `scan_lag`, and the host-local datadir-lock documentation). + +## [0.5.0-rc1] - 2026-07-09 + +### Added +- Ironwood (NU6.3 / V6) support: receive discovery, balance rollup, `pool == "ironwood"` labelling, and the Ironwood send and proof path, with an NU6.3 regtest end-to-end covering receive, send, and memo decryption. Compiled unconditionally and activated by consensus height - on for testnet, off for mainnet, opt-in on regtest - so no build flag is needed. Pins librustzcash to a working zecrocks fork, to be repointed to mainline before release. + ## [0.4.3] - 2026-07-12 ### Added @@ -163,6 +179,9 @@ Zcash, backed entirely by librustzcash and running as a light client. ### Security - Pre-release audit hardening; refuse to start on mainnet with the placeholder RPC password; enforce a 12-character passphrase minimum. +[0.5.0-rc3]: https://github.com/zecrocks/zecd/compare/v0.5.0-rc2...v0.5.0-rc3 +[0.5.0-rc2]: https://github.com/zecrocks/zecd/compare/v0.5.0-rc1...v0.5.0-rc2 +[0.5.0-rc1]: https://github.com/zecrocks/zecd/compare/v0.4.2...v0.5.0-rc1 [0.4.3]: https://github.com/zecrocks/zecd/compare/v0.4.2...v0.4.3 [0.4.2]: https://github.com/zecrocks/zecd/compare/v0.4.1...v0.4.2 [0.4.1]: https://github.com/zecrocks/zecd/compare/v0.4.0...v0.4.1 diff --git a/Cargo.lock b/Cargo.lock index bed8735..1b9e219 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,9 +25,9 @@ dependencies = [ [[package]] name = "age" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d86e4272c093c88caf7864a2d09af52a5159180848ca4832a3cdbd7d014d5" +checksum = "22a9e0aa470b0ca06dcf4bcf513ccce90301df1cad9bd08221e4377c653aef13" dependencies = [ "age-core", "base64 0.21.7", @@ -133,9 +133,9 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -148,9 +148,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-trait" @@ -343,9 +343,9 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -439,9 +439,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cbc" @@ -454,9 +454,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "shlex", @@ -612,18 +612,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -640,9 +640,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -706,9 +706,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -716,11 +716,10 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", @@ -730,9 +729,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", @@ -741,12 +740,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" -dependencies = [ - "powerfmt", -] +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "derive-getters" @@ -954,6 +950,18 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + [[package]] name = "fmutex" version = "0.3.0" @@ -964,12 +972,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -1145,30 +1147,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasip2", - "wasip3", ] [[package]] name = "getset" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ - "proc-macro-error2", "proc-macro2", "quote", "syn", @@ -1507,12 +1508,6 @@ dependencies = [ "syn", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1546,8 +1541,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] @@ -1607,9 +1600,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.100" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -1648,12 +1641,6 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -1709,9 +1696,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "matchers" @@ -1740,9 +1727,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memuse" @@ -1788,6 +1775,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "nom" version = "7.1.3" @@ -1815,9 +1811,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -1825,9 +1821,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1878,9 +1874,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "orchard" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a54f8d29bfb1e76a9d4e868a1a08cce2e57dd2bdc66232982822ad3114b91ab3" +checksum = "cca2ede6a4a77bd729eed3be9303d98a08b217edc85190dcf4dbe004086d5a65" dependencies = [ "aes", "bitvec", @@ -1983,9 +1979,9 @@ dependencies = [ [[package]] name = "pczt" -version = "0.7.0" +version = "0.8.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4712bd8688e1d1aa0eb0e78dc5c846bc2d70dcd1de544ecf053516df174c4fcd" +checksum = "e8fb3f5ea64b891d40cff8182aca0bd7d063c9460920546fe7066860b781de91" dependencies = [ "blake2b_simd", "bls12_381", @@ -2202,9 +2198,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -2223,9 +2219,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", @@ -2323,9 +2319,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -2335,9 +2331,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -2426,9 +2422,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2454,9 +2450,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -2690,11 +2686,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "serde_core", @@ -2705,9 +2702,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", @@ -2727,9 +2724,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures", @@ -2769,9 +2766,9 @@ dependencies = [ [[package]] name = "shardtree" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "359e552886ae54d1642091645980d83f7db465fd9b5b0248e3680713c1773388" +checksum = "9cc96e9b6b1c307749019e7e2cf9dd29881b1928ce6866e3702a02a4800aa345" dependencies = [ "bitflags", "either", @@ -2814,9 +2811,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -2863,9 +2860,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -2891,7 +2888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2939,39 +2936,38 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.37" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.19" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -2990,9 +2986,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3223,7 +3219,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" dependencies = [ - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", ] [[package]] @@ -3278,12 +3274,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -3302,11 +3292,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -3416,29 +3406,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-bindgen" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -3449,9 +3421,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3459,9 +3431,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -3472,52 +3444,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "which" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" dependencies = [ "libc", ] @@ -3628,100 +3566,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "wyz" version = "0.5.1" @@ -3751,9 +3595,9 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" [[package]] name = "zcash_address" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58342d0aaa8e2fa98849636f52800ac4bf020574c944c974742fc933db58cac2" +checksum = "5a854b28c07dba372f4410ea8ad62b4bf7d5c2bf8be32fc4b31bc0db6521a975" dependencies = [ "bech32 0.11.1", "bs58", @@ -3765,9 +3609,9 @@ dependencies = [ [[package]] name = "zcash_client_backend" -version = "0.23.0" +version = "0.24.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04583f0945d5ff80eee80c1844fb0af8b7cc46a0ac26d5e12c4ccd474bec0f03" +checksum = "eb0d78dcc345c36086112886223f6f1d6d224802808f15ac4f3ef144677029a1" dependencies = [ "base64 0.22.1", "bech32 0.11.1", @@ -3775,8 +3619,8 @@ dependencies = [ "bls12_381", "bs58", "byteorder", - "crossbeam-channel", "document-features", + "flume", "getset", "group", "hex", @@ -3798,7 +3642,6 @@ dependencies = [ "shardtree", "subtle", "time", - "time-core", "tonic-prost-build", "tracing", "which", @@ -3816,9 +3659,9 @@ dependencies = [ [[package]] name = "zcash_client_sqlite" -version = "0.21.0" +version = "0.22.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b99abb0e534b72705f49cb43d5f1a448844b49b9c48590f87888cb84ce6d29" +checksum = "9aa9b039e66a3b79f2de45ebf86cd82545232d332631a84f7c017ae21572b8de" dependencies = [ "bip32", "bitflags", @@ -3874,9 +3717,9 @@ dependencies = [ [[package]] name = "zcash_keys" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbcdfbb5c8edb247439d72a397abaae9b7dd14a1c070e7e4fc3536924f9065f" +checksum = "36391ebfce7df2510c6564f79e608ed93f1c0692c6464e2397b248e06dae3912" dependencies = [ "bech32 0.11.1", "bip32", @@ -3904,9 +3747,9 @@ dependencies = [ [[package]] name = "zcash_note_encryption" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77efec759c3798b6e4d829fcc762070d9b229b0f13338c40bf993b7b609c2272" +checksum = "e1cb1b9170c94370e3d66c5cc0877661db743337588b64de7711239eed462198" dependencies = [ "chacha20", "chacha20poly1305", @@ -3917,9 +3760,9 @@ dependencies = [ [[package]] name = "zcash_primitives" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c69e07f5eb3f682a6467b4b08ee4956f1acd1e886d70b21c4766953b3a1beba2" +checksum = "bdbeccc05bfe63b6dee9e989c6ff5027421741562a4853c36504dd621f6a81b1" dependencies = [ "blake2b_simd", "block-buffer 0.11.0-rc.3", @@ -3948,9 +3791,9 @@ dependencies = [ [[package]] name = "zcash_proofs" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3de6b0ca82e08a9d38b1121f87c5b180b5feac19fecba074cb582882210d2371" +checksum = "b91fb0b420fb66a765f1fa92ab7a6b631aa54c08415415ef6185256826e74fbd" dependencies = [ "bellman", "blake2b_simd", @@ -3971,9 +3814,9 @@ dependencies = [ [[package]] name = "zcash_protocol" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bec496a0bd62dae98c4b26f51c5dab112d0c5350bbc2ccfdfd05bb3454f714d" +checksum = "2973d210e74ebd81adc50828fbbb284ab6aaa099308097c42c47dc63cf3420fc" dependencies = [ "corez", "document-features", @@ -4010,9 +3853,9 @@ dependencies = [ [[package]] name = "zcash_transparent" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15df1908b428d4edeb7c7caae5692e05e2e92e5c38007a40b20ac098efdffd96" +checksum = "72e0f8c142bed366ed5886dcfa8772ec90b5420cc127e6cdb76b6744c53a287b" dependencies = [ "bip32", "bs58", @@ -4035,7 +3878,7 @@ dependencies = [ [[package]] name = "zecd" -version = "0.4.3" +version = "0.5.0-rc3" dependencies = [ "age", "anyhow", @@ -4091,18 +3934,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.51" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5361301a1d9e5dd94c524eb99365fbaed5b237e831d7f45e2ddea11ffe8627" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.51" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "422033a2245cb4b6ff8def11b2dfaf184a2ab2573f5af28082a163a68889af0e" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -4117,18 +3960,18 @@ checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", @@ -4160,9 +4003,9 @@ dependencies = [ [[package]] name = "zip321" -version = "0.8.0" +version = "0.9.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fef061351aa99792deb8f62b00426863a317ca2cb124c9de85550e3ef0f0f2f" +checksum = "cb464491970d9b61889e4f2b7b00fb9f471a33692e5c0de3122fdc575d8067ab" dependencies = [ "base64 0.22.1", "nom", @@ -4173,6 +4016,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 383154e..356f7a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zecd" -version = "0.4.3" +version = "0.5.0-rc3" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" @@ -70,8 +70,8 @@ uuid = { version = "1", features = ["v4", "serde"] } # `circuit` exposes `orchard::circuit::{ProvingKey, VerifyingKey}` so we can build the Orchard # proving key once at startup and reuse it for every send (the PCZT prove path), instead of # librustzcash's fused builder which rebuilds it per transaction. -orchard = { version = "0.14", default-features = false, features = ["circuit"] } -zcash_address = "0.12" +orchard = { version = "0.15", default-features = false, features = ["circuit"] } +zcash_address = "0.13" # `CompactSize` builds the length-prefixed preimage for the `signmessage`/`verifymessage` # message hash (magic + message, each CompactSize-prefixed, double-SHA256), byte-identical to # zcashd's `rpc/misc.cpp` and zallet. @@ -80,22 +80,22 @@ zcash_encoding = "0.4" # and the received-by-address read paths; zecd's own wallets never expose transparent receivers. # `pczt` exposes `create_pczt_from_proposal` / `extract_and_store_transaction_from_pczt`, the # build/prove/store seam that lets the proving step take a cached Orchard proving key. -zcash_client_backend = { version = "0.23", features = ["orchard", "transparent-inputs", "pczt"] } -zcash_client_sqlite = { version = "0.21", features = ["unstable", "orchard", "serde", "transparent-inputs"] } -zcash_keys = { version = "0.14", features = ["unstable", "orchard"] } -zcash_primitives = "0.28" -zcash_proofs = { version = "0.28", features = ["bundled-prover"] } -zcash_protocol = { version = "0.9", features = ["local-consensus"] } -zcash_transparent = "0.8" +zcash_client_backend = { version = "0.24.0-rc.1", features = ["orchard", "transparent-inputs", "pczt"] } +zcash_client_sqlite = { version = "0.22.0-rc.1", features = ["unstable", "orchard", "serde", "transparent-inputs"] } +zcash_keys = { version = "0.15", features = ["unstable", "orchard"] } +zcash_primitives = "0.29" +zcash_proofs = { version = "0.29", features = ["bundled-prover"] } +zcash_protocol = { version = "0.10", features = ["local-consensus"] } +zcash_transparent = "0.9" # Script asm rendering + standard-script solver for `getrawtransaction` (already in the # tree via zcash_transparent; same crate Zallet uses for this). zcash_script = "0.4.5" sapling = { package = "sapling-crypto", version = "0.7" } # PCZT roles (Prover/Signer) for the cached-proving-key send path. Versions track the -# zcash_client_backend `pczt` feature's own pin (0.7). -pczt = { version = "0.7", default-features = false, features = ["prover", "signer", "orchard", "sapling"] } +# zcash_client_backend `pczt` feature's own pin (0.8.0-rc.1). +pczt = { version = "0.8.0-rc.1", default-features = false, features = ["prover", "signer", "orchard", "sapling"] } zip32 = "0.2" -zip321 = "0.8" +zip321 = "0.9.0-rc.1" libc = "0.2.186" # Single-instance guard: an advisory exclusive lock on `/.lock` so two zecd processes # can't operate on the same data directory at once (mirrors zallet / zcashd). Linux only needs @@ -119,16 +119,65 @@ mimalloc = ["dep:mimalloc"] # mitigations that swapping out musl's `malloc-ng` would otherwise drop. `mimalloc` (non-secure) # remains available for anyone who wants the last few percent. See FINDINGS.md. mimalloc-secure = ["mimalloc", "mimalloc/secure"] +# NB: Ironwood (NU6.3 / V6) is **no longer a cargo feature** - it is compiled unconditionally (the +# published ironwood librustzcash line exposes the ironwood APIs unconditionally). +# Activation is purely consensus-height driven via `is_nu_active(NetworkUpgrade::Nu6_3, ...)`: OFF on +# mainnet (protocol has no NU6.3 height), ON on testnet at height 4_134_000, and opt-in on regtest +# via the `ZECD_REGTEST_NU63_HEIGHT` env var (the ironwood harness sets it to 8; the standard harness +# leaves it unset). So ironwood is "always built, testnet-on, mainnet-off" with no build flag. The +# wallet crates are pinned to crates.io **release candidates** (see the version reqs above); move to +# the finals when they publish. [dev-dependencies] tempfile = "3" # Frontier bookkeeping for the fabricated-chain reorg test (matches librustzcash's version). incrementalmerkletree = "0.8" -# The ONLY patched dependency (librustzcash etc. stay on released crates.io versions - -# see the project docs): a vendored i18n-embed-fl 0.9.4 with the deterministic-argument-order -# fix from kellpossible/cargo-i18n#151 backported, so `age`'s fl! macro expansions (and -# therefore the reproducible Docker builds) stop being a per-build coin flip. Remove -# when an `age` release depends on i18n-embed-fl 0.10+. +# Dependency notes. +# +# The ironwood / NU6.3 librustzcash line is now consumed **straight from crates.io** - the leaf +# crates as finals (`zcash_primitives`/`zcash_proofs 0.29.0`, `orchard 0.15.0`, `zcash_protocol +# 0.10.0`, `zcash_keys 0.15.0`, `zcash_address 0.13.0`, `zcash_transparent 0.9.0`) and the wallet +# crates as release candidates (`zcash_client_backend 0.24.0-rc.1`, `zcash_client_sqlite +# 0.22.0-rc.1`, `pczt 0.8.0-rc.1`, `zip321 0.9.0-rc.1`), published 2026-07-12. This replaced the +# previous all-or-nothing `[patch.crates-io]` git pin of the whole librustzcash workspace to the +# `dw/ironwood-scan-model` rev `1105d8203` (zcash/librustzcash#2539), which existed only because the +# wallet crates had no ironwood release yet. The shardtree/incrementalmerkletree git pins went with +# it: this line depends on published `shardtree 0.7` / `incrementalmerkletree 0.8.2`, which carry the +# explicit-checkpoint-retention APIs and the `clear_flags` checkpoint-pruning panic fix the old pins +# existed for. Zecd carries **no librustzcash fork and no git pins** - move the RC reqs to the finals +# when they publish. +# +# Still-relevant notes inherited from the git-pin era (they describe the published line too): +# +# - **Self-send memo backfill is NOT in this line.** A cherry-pick (submitted upstream separately) +# filled a received note's NULL memo for the wallet's *own* mined self-sends via a `put_blocks` +# re-decryption pass. The gap is narrow and self-healing (the live mempool path fills the memo on +# the authoring node; a from-seed restore recovers it via enhancement) - it only bites the +# authoring node when the mempool missed its own self-send, and a rescan fixes it. Re-check when +# bumping to the next RC/final. +# - **Ironwood sends route through the fused `create_proposed_transactions` path** via the +# `ironwood_forces_fused` gate in `actor.rs` (~4.5 s/send keygen). NB the published RC now BUILDS +# Ironwood PCZTs (librustzcash#2543 landed - the old git rev rejected them), so the gate is a +# conservative choice, not an upstream constraint; drop it (and reclaim the PCZT proving-key cache) +# once the cached path is validated against an NU6.3-active chain. +# - **Single-pool-group input selection** (upstream `00c789f9`): shielded inputs come from one of +# two mutually-exclusive groups - {Orchard} or {Sapling+Ironwood}, never mixed - the NU6.3 +# migration model (past NU6.3 a new Orchard-address output becomes an ironwood V3 note, so Orchard +# V2 is a legacy drain-only pool). Applied *unconditionally* (pre-NU6.3 too), so the default +# (NU6.2) build no longer combines Sapling+Orchard inputs in one transaction; the `regtest_sapling` +# e2e was updated to the turnstile model to match. +# - **Ironwood model** (matches devtool): ironwood notes are **Orchard V3 notes** - same key tree, +# same key derivation, scanned by the existing Orchard path, stored in `ironwood_received_notes` +# and surfaced in `v_received_outputs` with pool code 4; balances roll up via +# `AccountBalance::ironwood_balance()`. **Sends**: the proposal auto-routes Orchard payment/change +# into the ironwood bundle past NU6.3, and the prover adds an ironwood proof step +# (`requires_ironwood_proof`/`create_ironwood_proof`) using a `PostNu6_3` proving key (see +# `actor.rs`; mirrors devtool's `pczt/prove.rs`). +# +# The only remaining patch: i18n-embed-fl, a vendored 0.9.4 with the deterministic-argument-order +# fix from kellpossible/cargo-i18n#151 backported, so `age`'s fl! macro expansions (and therefore the +# reproducible Docker builds) stop being a per-build coin flip. Remove when an `age` release depends +# on i18n-embed-fl 0.10+. [patch.crates-io] i18n-embed-fl = { path = "vendor/i18n-embed-fl" } diff --git a/deny.toml b/deny.toml index a63b284..e034415 100644 --- a/deny.toml +++ b/deny.toml @@ -50,4 +50,17 @@ wildcards = "deny" [sources] unknown-registry = "deny" unknown-git = "deny" -allow-git = [] +# Dev-only ironwood / NU6.3 branch exception (see Cargo.toml `[patch.crates-io]` and the project docs): +# the librustzcash set is patched by git rev to upstream `zcash/librustzcash` +# `dw/ironwood-scan-model` - a plain upstream pin, no fork. The ironwood wallet crates +# (client_backend/client_sqlite/pczt/zip321/encoding) aren't published to crates.io yet, so the whole +# `[patch]` set is git-pinned by rev; repin to crates.io once they publish. The branch also requires +# the explicit-checkpoint-retention shardtree APIs plus a `clear_flags` checkpoint-pruning panic fix, +# so shardtree + incrementalmerkletree are git-patched to `zcash/incrementalmerkletree` main (which now +# carries both; mirror the rev the `[patch]` pins). `orchard` resolves from crates.io (0.15.0-pre.x). +# Drop both git sources when the ironwood wallet crates + a shardtree release with these APIs land on +# crates.io. +allow-git = [ + "https://github.com/zcash/librustzcash", + "https://github.com/zcash/incrementalmerkletree", +] diff --git a/deploy/docker-compose.mainnet.yml b/deploy/docker-compose.mainnet.yml index 419896f..6becf44 100644 --- a/deploy/docker-compose.mainnet.yml +++ b/deploy/docker-compose.mainnet.yml @@ -1,5 +1,6 @@ # Mainnet override for the self-hosted stack. The base docker-compose.yml is testnet by default; -# this override swaps each service's config file for its mainnet variant. Ports and wiring are +# this override swaps each service's config file for its mainnet variant. The zebra image is +# unchanged (6.0.0 activates ironwood at the network's activation height). Ports and wiring are # unchanged, so the same workflow applies - just add `-f docker-compose.mainnet.yml`: # # docker compose -f docker-compose.yml -f docker-compose.mainnet.yml up -d zebra diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index cce5471..f1adfed 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -16,6 +16,8 @@ services: zebra: + # Zebra 6.0.0 activates ironwood (NU6.3) at the network's activation height (see zebra's source + # and release notes). The mainnet overlay reuses this image. image: zfnd/zebra:6.0.0 # pin to a verified release (zebra tags have no 'v' prefix) restart: unless-stopped command: ["zebrad", "-c", "/etc/zebra/zebrad.toml", "start"] diff --git a/regtest-harness/src/lib.rs b/regtest-harness/src/lib.rs index 0084cf4..a24ad90 100644 --- a/regtest-harness/src/lib.rs +++ b/regtest-harness/src/lib.rs @@ -4,7 +4,7 @@ //! its Bitcoin-Core-style JSON-RPC - `zecd` talks straight to zebrad. There is intentionally //! **no `zingo-infra`/`zcash_local_net` dependency**, and no compile-time zebra dependency //! either: blocks are mined with zebrad's own Regtest-only `generate` RPC (zebra ≥ 2.0.0), -//! which runs the template→assemble→submit flow server-side against the node's own network +//! which runs the template->assemble->submit flow server-side against the node's own network //! parameters. The harness is a pure black-box JSON-RPC driver, so it works unmodified against //! any zebrad release. //! @@ -46,6 +46,12 @@ pub fn resolve_bin(env_var: &str) -> Option { /// only accrues once NU6 is live - so NU6.1/NU6.2 activate a few blocks in, after a pool exists. /// Must match `zecd`'s `network::regtest`. const NU6_2_ACTIVATION_HEIGHT: u32 = 4; +/// Height at which NU6.3 (ironwood) activates on the ironwood regtest chain. Only emitted into the +/// zebra config for the ironwood tier (stock zebra rejects the `"NU6.3"` key). This value is the +/// canonical reference for the cross-component schedule: it MUST equal `zecd`'s `network::regtest()` +/// `nu6_3` height and `devtool`'s `regtest_params()` - a mismatch diverges the NU6.3 consensus +/// branch id and zebra rejects the tx (loud failure, not silent). +pub const NU6_3_ACTIVATION_HEIGHT: u32 = 8; /// ZIP-271 one-time lockbox disbursement paid in the NU6.1 activation block's coinbase. A P2SH /// regtest address and a token amount (<= the pool accrued by [`NU6_2_ACTIVATION_HEIGHT`]). const LOCKBOX_DISBURSEMENT_ADDR: &str = "t27eWDgjFYJGVXmzrXeVjnb5J3uXDM9xH9v"; @@ -63,6 +69,9 @@ pub struct Zebrad { net_port: u16, bin: PathBuf, config_path: PathBuf, + /// NU6.3 (ironwood) activation height, or `None` for a stock-zebra (non-ironwood) chain. + /// Preserved across `restart_with_miner` so the rebuilt config keeps the same schedule. + nu6_3_height: Option, _dir: tempfile::TempDir, } @@ -105,6 +114,21 @@ impl Zebrad { /// Launch `zebrad` mining its coinbase to `miner_address`, so a wallet that controls that /// address can spend the matured coinbase (used to fund the Orchard wallet under test). pub async fn start_with_miner(bin: &Path, miner_address: &str) -> Result { + Self::start_inner(bin, miner_address, None).await + } + + /// Launch `zebrad` on the ironwood regtest chain (NU6.3 active at [`NU6_3_ACTIVATION_HEIGHT`]), + /// mining its coinbase to `miner_address`. Requires an ironwood-capable zebrad (the zakura + /// image) - stock zebra rejects the `"NU6.3"` activation-height key. + pub async fn start_with_miner_ironwood(bin: &Path, miner_address: &str) -> Result { + Self::start_inner(bin, miner_address, Some(NU6_3_ACTIVATION_HEIGHT)).await + } + + async fn start_inner( + bin: &Path, + miner_address: &str, + nu6_3_height: Option, + ) -> Result { let dir = tempfile::tempdir().context("create zebrad dir")?; let rpc_port = pick_port()?; let net_port = pick_port()?; @@ -117,6 +141,7 @@ impl Zebrad { rpc_port, miner_address, &cache_dir.to_string_lossy(), + nu6_3_height, ), ) .context("write zebrad.toml")?; @@ -127,6 +152,7 @@ impl Zebrad { net_port, bin: bin.to_path_buf(), config_path, + nu6_3_height, _dir: dir, }; zebrad.wait_until_rpc_up().await?; @@ -163,6 +189,7 @@ impl Zebrad { self.rpc_port, miner_address, &cache_dir.to_string_lossy(), + self.nu6_3_height, ), ) .context("rewrite zebrad.toml for restart")?; @@ -209,7 +236,7 @@ impl Zebrad { } /// Mine `n` blocks via zebrad's Regtest-only `generate` RPC (zebra ≥ 2.0.0). Server-side it - /// runs the same `getblocktemplate` → assemble → `submitblock` flow zebra's own regtest tests + /// runs the same `getblocktemplate` -> assemble -> `submitblock` flow zebra's own regtest tests /// use, against the node's own network parameters - so the harness needs no zebra crates and /// can't drift from the running node's consensus rules. Regtest disables PoW, so there is no /// solving step. @@ -251,10 +278,22 @@ impl Drop for Zebrad { /// zebrad Regtest config for zebra 5.x. Note: no `[mining] debug_like_zcashd` (removed after /// 2.x), `disable_pow = true` so submitted blocks need no PoW, and `enable_cookie_auth = false` /// so lightwalletd can use the rpcuser/rpcpassword from its `zcash.conf`. -fn zebrad_toml(net_port: u16, rpc_port: u16, miner_address: &str, cache_dir: &str) -> String { +fn zebrad_toml( + net_port: u16, + rpc_port: u16, + miner_address: &str, + cache_dir: &str, + nu6_3_height: Option, +) -> String { let nu6_2 = NU6_2_ACTIVATION_HEIGHT; let lockbox_addr = LOCKBOX_DISBURSEMENT_ADDR; let lockbox_amount = LOCKBOX_DISBURSEMENT_ZATS; + // NU6.3 (ironwood) activation line, emitted only for the ironwood tier - stock zebra has no + // `"NU6.3"` key and rejects an unknown activation-height entry at startup. + let nu6_3_line = match nu6_3_height { + Some(h) => format!("\"NU6.3\" = {h}\n"), + None => String::new(), + }; format!( r#"[network] network = "Regtest" @@ -274,7 +313,7 @@ NU5 = 1 NU6 = 1 "NU6.1" = {nu6_2} "NU6.2" = {nu6_2} - +{nu6_3_line} # A deferred (lockbox) funding stream so the pool has something to disburse at NU6.1. [[network.testnet_parameters.funding_streams]] [network.testnet_parameters.funding_streams.height_range] @@ -435,8 +474,8 @@ abandon abandon abandon art"; /// funds to the `zecd` wallet under test. Resolve the binary via `$DEVTOOL_BIN`. /// /// Regtest can't mine a coinbase straight into an Orchard note that `zecd` (Orchard-only) would -/// see, so this is how we get funds into `zecd`: mine transparent coinbase → mature (101 blocks) → -/// shield to Orchard → send to `zecd`'s unified address. +/// see, so this is how we get funds into `zecd`: mine transparent coinbase -> mature (101 blocks) -> +/// shield to Orchard -> send to `zecd`'s unified address. pub struct Funder { bin: PathBuf, dir: tempfile::TempDir, @@ -478,15 +517,50 @@ impl Funder { /// which needs a real block - `birthday 0/1` requests genesis and is rejected). The funder's /// transparent coinbase is detected regardless of birthday. pub fn init(bin: &Path, lwd_port: u16) -> Result { + Self::init_inner(bin, lwd_port, false) + } + + /// Initialise the funding wallet on the **ironwood** regtest chain (NU6.3 active). The ironwood + /// `zcash-devtool` (`regtest_support` feature) differs from the older standard-tier build on two + /// `init` points: (1) `-n regtest` requires an explicit `--activation-heights ` (persisted + /// into the wallet so its per-build consensus branch ID matches the launched zebra), and (2) the + /// mnemonic is read from **stdin** (piped) rather than a `--mnemonic` flag (which it no longer + /// accepts). [`Funder::init`] keeps the old `--mnemonic`/no-heights shape for the standard tier. + pub fn init_ironwood(bin: &Path, lwd_port: u16) -> Result { + Self::init_inner(bin, lwd_port, true) + } + + fn init_inner(bin: &Path, lwd_port: u16, ironwood: bool) -> Result { let dir = tempfile::tempdir().context("create funder dir")?; let funder = Funder { bin: bin.to_path_buf(), dir, }; let identity = funder.identity(); - funder.run( - "init", - &[ + if ironwood { + // Newer devtool: mnemonic piped to stdin, activation heights via file. + let heights_path = funder.write_activation_heights()?; + let args = [ + "--name", + "funder", + "--network", + "regtest", + "--identity", + &identity, + "--birthday", + "2", + "--activation-heights", + &heights_path, + ]; + funder.run_with_stdin( + "init", + &args, + Some(lwd_port), + &format!("{FUNDER_MNEMONIC}\n"), + )?; + } else { + // Older devtool: mnemonic via `--mnemonic`, no activation heights. + let args = [ "--name", "funder", "--network", @@ -497,12 +571,37 @@ impl Funder { FUNDER_MNEMONIC, "--birthday", "2", - ], - Some(lwd_port), - )?; + ]; + funder.run("init", &args, Some(lwd_port))?; + } Ok(funder) } + /// Write the `--activation-heights` TOML the ironwood `zcash-devtool` requires for `init`, in the + /// `ActivationHeights` schema (one optional height per network upgrade). These MUST match zecd's + /// `network::regtest()` and the zebra config the harness launches (`zebrad_toml`): NU5/NU6 from + /// height 1, NU6.1/NU6.2 at [`NU6_2_ACTIVATION_HEIGHT`], NU6.3 (Ironwood) at + /// [`NU6_3_ACTIVATION_HEIGHT`] - any drift makes the validator reject the funder's transactions. + fn write_activation_heights(&self) -> Result { + let path = self.dir.path().join("activation-heights.toml"); + let toml = format!( + "overwinter = 1\n\ + sapling = 1\n\ + blossom = 1\n\ + heartwood = 1\n\ + canopy = 1\n\ + nu5 = 1\n\ + nu6 = 1\n\ + nu6_1 = {nu62}\n\ + nu6_2 = {nu62}\n\ + nu6_3 = {nu63}\n", + nu62 = NU6_2_ACTIVATION_HEIGHT, + nu63 = NU6_3_ACTIVATION_HEIGHT, + ); + std::fs::write(&path, toml).context("write activation-heights.toml")?; + Ok(path.to_string_lossy().into_owned()) + } + fn identity(&self) -> String { self.dir .path() @@ -599,6 +698,62 @@ impl Funder { } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } + + /// Like [`Funder::run`], but pipes `stdin_data` to the child's stdin (then closes it, signalling + /// EOF). Used for the newer devtool `init`, which reads the mnemonic phrase from stdin instead of + /// a `--mnemonic` flag when stdin is not a terminal (as under `cargo test`). + fn run_with_stdin( + &self, + subcommand: &str, + extra: &[&str], + lwd_port: Option, + stdin_data: &str, + ) -> Result { + use std::io::Write; + use std::process::Stdio; + + let mut args: Vec = vec![ + "wallet".into(), + "-w".into(), + self.wallet_dir(), + subcommand.into(), + ]; + args.extend(extra.iter().map(|s| s.to_string())); + if let Some(port) = lwd_port { + args.extend([ + "--server".into(), + format!("127.0.0.1:{port}"), + "--connection".into(), + "direct".into(), + ]); + } + let mut child = Command::new(&self.bin) + .args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("spawn devtool {subcommand}"))?; + { + let mut stdin = child.stdin.take().context("devtool stdin not piped")?; + stdin + .write_all(stdin_data.as_bytes()) + .with_context(|| format!("write stdin to devtool {subcommand}"))?; + // Dropping `stdin` here closes the pipe, so the child sees EOF after the phrase. + } + let output = child + .wait_with_output() + .with_context(|| format!("wait for devtool {subcommand}"))?; + if !output.status.success() { + bail!( + "devtool {subcommand} failed ({}):\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + tail(&String::from_utf8_lossy(&output.stderr), 30), + ); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } } // =============================== zecd (the system under test) =============================== @@ -1483,7 +1638,7 @@ fn write_zecd_toml(datadir: &Path, cfg: &ZecdConfig) -> Result<()> { )); } // Optional [pools] section (multi-pool / Sapling e2e, and/or transparent receiving); omitted - // entirely → Orchard-only, no transparent. + // entirely -> Orchard-only, no transparent. let pools = if cfg.pools.is_some() || cfg.transparent { let mut s = String::from("\n[pools]\n"); if let Some((enabled, receivers)) = &cfg.pools { diff --git a/regtest-harness/tests/regtest_ironwood.rs b/regtest-harness/tests/regtest_ironwood.rs new file mode 100644 index 0000000..c07024b --- /dev/null +++ b/regtest-harness/tests/regtest_ironwood.rs @@ -0,0 +1,834 @@ +//! Ironwood (NU6.3) regtest end-to-end. Three tests: +//! * `regtest_ironwood_receive_and_orchard_send` - zecd **receives an ironwood note**, then +//! **spends it** - an ironwood->ironwood send: the wallet's single spendable input is pinned to +//! the ironwood pool, and past NU6.3 the payment + change route into the ironwood bundle (the +//! 0.3 payment output is verified `pool == "ironwood"`), so the send only broadcasts if zecd's +//! ironwood proof step ran. +//! * `regtest_ironwood_sapling_send` - zecd spends a **Sapling** note and produces an **ironwood** +//! output (a Sapling->ironwood turnstile), starting from a wallet that held no ironwood note. +//! * `regtest_ironwood_receive_memo` - the funder attaches a ZIP-302 text memo to a post-NU6.3 +//! send, so zecd receives an **ironwood note carrying a memo**. Proves the Ironwood-domain memo +//! decryption path (`decrypt_transaction` decrypting the Ironwood bundle) end-to-end: the memo +//! is surfaced on the received output (`memoStr`/`memo`) on a note asserted `pool == "ironwood"`. +//! +//! Requires the full ironwood toolchain - the official ironwood zebra release (`zfnd/zebra:6.0.0`), +//! a V6-parsing lightwalletd, an ironwood/regtest-aware `zcash-devtool` funder (its regtest `init` +//! is given `--activation-heights` via [`Funder::init_ironwood`]), and a plain-release `zecd` +//! (ironwood is compiled unconditionally now - no cargo feature) with `ZECD_REGTEST_NU63_HEIGHT=8` +//! in its environment so NU6.3 activates at height 8 on the regtest chain (matching zebra). +//! Gated behind `ZECD_REGTEST_IRONWOOD=1` (its own CI tier) so it never runs against the stock-zebra +//! funded tier - there `Zebrad::start_with_miner_ironwood`'s `"NU6.3"` activation-height key would +//! be rejected at startup. +//! +//! Flow (no `migrate` needed): mine a transparent coinbase to the funder on an NU6.3-active chain, +//! mature it, `shield` into Orchard, then `wallet send` to zecd's unified address. Post-NU6.3 the +//! proposal builder auto-routes the Orchard payment to an **ironwood** output (the +//! `orchard_outputs_to_ironwood` path), so zecd scans an ironwood (V3) note at its Orchard receiver. +//! +//! Asserts both that the note is labelled ironwood (`listunspent`'s `pool == "ironwood"`, sourced +//! from `v_tx_outputs.output_pool` = 4) and that its value lands in `getbalance`. The build-time +//! receive wiring (sync/treestate/subtrees/compact-actions) is unit-green; this is the live +//! integration proof and is expected to need timing iteration on the docker stack. + +use std::time::{Duration, Instant}; + +use serde_json::json; +use zecd_regtest_harness::{ + pick_port, resolve_bin, Funder, Lightwalletd, Zebrad, Zecd, ZecdConfig, +}; + +/// Coinbase blocks mined to the funder up front (see `regtest_funded.rs` for the finalization +/// rationale). The tip ends far past `NU6_3_ACTIVATION_HEIGHT` (8), so NU6.3 is active for the send. +const FUNDER_COINBASES: u32 = 120; +/// Maturity tail mined to a throwaway address after the miner swap, so the funder's coinbases age +/// past the 100-block maturity. +const MATURITY_TAIL: u32 = 130; +/// A throwaway P2SH address that mines the maturity tail (the funder does not control it). +const TAIL_MINER_ADDRESS: &str = "t27eWDgjFYJGVXmzrXeVjnb5J3uXDM9xH9v"; +/// 1 ZEC, in zatoshis. +const FUND_ZATOSHIS: u64 = 100_000_000; +/// Generous: lightwalletd ingestion + zecd scan + Orchard/ironwood proving. +const FUND_TIMEOUT: Duration = Duration::from_secs(240); + +#[tokio::test] +async fn regtest_ironwood_receive_and_orchard_send() { + if std::env::var("ZECD_REGTEST_IRONWOOD").is_err() { + eprintln!( + "SKIP regtest_ironwood_receive_and_orchard_send: set ZECD_REGTEST_IRONWOOD=1 (plus the \ + ironwood ZEBRAD_BIN/LIGHTWALLETD_BIN/DEVTOOL_BIN and an ironwood-built ZECD_BIN) to run \ + the NU6.3 e2e. The harness still compiled and linked." + ); + return; + } + let (Some(zebrad_bin), Some(lwd_bin), Some(devtool_bin)) = ( + resolve_bin("ZEBRAD_BIN"), + resolve_bin("LIGHTWALLETD_BIN"), + resolve_bin("DEVTOOL_BIN"), + ) else { + panic!( + "ZECD_REGTEST_IRONWOOD=1 but ZEBRAD_BIN/LIGHTWALLETD_BIN/DEVTOOL_BIN are not all set" + ); + }; + + // 1. Learn the funder's transparent address offline, so zebra mines straight to it (one chain). + let funder_taddr = + Funder::derive_transparent_address(&devtool_bin).expect("derive funder transparent addr"); + + // 2. Start an NU6.3-active regtest zebra (zakura), mine the funder's coinbases, then restart + // mining to a throwaway address and grow a maturity tail. NU6.3 is active from height 8, so + // every spend below lands on a V6/ironwood-capable chain. + let mut zebrad = Zebrad::start_with_miner_ironwood(&zebrad_bin, &funder_taddr) + .await + .expect("start ironwood zebrad mining to the funder"); + zebrad + .generate_blocks(FUNDER_COINBASES) + .await + .expect("mine the funder's coinbases"); + zebrad + .restart_with_miner(TAIL_MINER_ADDRESS) + .await + .expect("restart zebrad mining to the throwaway address"); + zebrad + .generate_blocks(MATURITY_TAIL) + .await + .expect("mine the maturity tail"); + + // 3. lightwalletd (V6-aware) in front of zebra, for the funder. + let lwd = Lightwalletd::start(&lwd_bin, zebrad.rpc_port) + .await + .expect("start lightwalletd"); + + // 4. Funder shields its matured transparent coinbase into Orchard. The ironwood devtool requires + // `init` to carry the regtest `--activation-heights` (matching this chain's NU6.3 height). + let funder = + Funder::init_ironwood(&devtool_bin, lwd.grpc_port).expect("initialise funding wallet"); + funder.sync(lwd.grpc_port).expect("funder sync (coinbase)"); + funder + .shield(lwd.grpc_port) + .expect("shield transparent coinbase into Orchard"); + zebrad.generate_blocks(6).await.expect("confirm shield"); + funder.sync(lwd.grpc_port).expect("funder sync (shielded)"); + + // 5. zecd (ironwood compiled unconditionally) against zebra; get its unified address. + let cfg = ZecdConfig::new(zebrad.rpc_port, pick_port().expect("pick zecd rpc port")); + let zecd = Zecd::start(&cfg) + .await + .expect("start zecd against ironwood regtest zebra"); + let zecd_ua = zecd + .call("getnewaddress", json!([])) + .await + .expect("getnewaddress"); + let zecd_ua = zecd_ua.as_str().expect("address string").to_string(); + // `getnewaddress` must NEVER return an empty response, and must stay a valid unified address + // even with NU6.3/ironwood active on this chain and an Orchard-only receiver config: ironwood + // has no distinct UA receiver (it reuses the Orchard receiver - a post-NU6.3 Orchard-address + // output is simply an ironwood V3 *note*), so the address derives from the account UFVK exactly + // as it does pre-NU6.3. A `None`/failed derivation would surface as a JSON-RPC *error*, never an + // empty success string. Assert it here so a regression fails at the call with a clear message + // rather than later as a downstream funding timeout. (`u`/`utest`/`uregtest` all start with `u`.) + assert!( + !zecd_ua.is_empty(), + "getnewaddress returned an empty address on an NU6.3-active chain (Orchard-only config)" + ); + assert!( + zecd_ua.starts_with('u'), + "getnewaddress returned a non-unified address on an NU6.3-active chain: {zecd_ua:?}" + ); + + // 6. Wait until zecd is fully caught up. + let deadline = Instant::now() + FUND_TIMEOUT; + loop { + let peers = zecd + .call("getpeerinfo", json!([])) + .await + .expect("getpeerinfo"); + if peers + .as_array() + .and_then(|a| a.first()) + .is_some_and(|p| p["conn_state"] == "ready") + { + break; + } + assert!( + Instant::now() < deadline, + "zecd never reached conn_state ready before funding: {peers}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // 7. Fund zecd. Post-NU6.3 the funder's `wallet send` auto-routes the Orchard payment to an + // ironwood output (no `migrate` needed), so zecd should receive an ironwood note at its + // Orchard receiver. + funder + .send(lwd.grpc_port, &zecd_ua, FUND_ZATOSHIS) + .expect("send funds to zecd (auto-routed to ironwood post-NU6.3)"); + zebrad + .generate_blocks(6) + .await + .expect("confirm funding send"); + + // 8. zecd scans the ironwood note: its value lands in `getbalance` and `listunspent` labels it + // `pool == "ironwood"` (sourced from `v_tx_outputs.output_pool` = 4). We poll listunspent at + // minconf 0 and mine until the ironwood entry appears, then cross-check the balance. + let expected = FUND_ZATOSHIS as f64 / 1e8; + let deadline = Instant::now() + FUND_TIMEOUT; + let ironwood_note = loop { + let unspent = zecd + .call("listunspent", json!([0])) + .await + .expect("listunspent"); + if let Some(note) = unspent + .as_array() + .expect("listunspent array") + .iter() + .find(|u| u["pool"] == "ironwood") + .cloned() + { + break note; + } + assert!( + Instant::now() < deadline, + "zecd never recorded an ironwood note; listunspent = {unspent}" + ); + // Advance the chain so the receive confirms (relabels from orchard to ironwood once mined) + // and the actor re-syncs. + zebrad.generate_blocks(2).await.expect("advance chain"); + tokio::time::sleep(Duration::from_secs(2)).await; + }; + assert_eq!( + ironwood_note["pool"], + json!("ironwood"), + "received note is in the ironwood pool: {ironwood_note}" + ); + assert!( + ironwood_note["amount"].as_f64().unwrap_or(0.0) > 0.0, + "ironwood note carries value: {ironwood_note}" + ); + + // The balance eventually reflects the ironwood receive once the note clears the confirmation + // policy. A foreign (received) note isn't spendable until `untrusted_confirmations` (ZIP-315: + // 10), so `getbalance` reads 0 right after the note first appears at 0-conf in `listunspent` + // above - keep mining until it confirms into the balance. A zecd (ironwood always compiled) sums + // `ironwood_balance()`, and the note is a V3 output `orchard_balance()` excludes, so a non-zero + // balance here is the ironwood value. + let deadline = Instant::now() + FUND_TIMEOUT; + loop { + let balance = zecd + .call("getbalance", json!([])) + .await + .expect("getbalance") + .as_f64() + .unwrap_or(0.0); + if balance >= expected - 0.001 { + break; + } + assert!( + Instant::now() < deadline, + "getbalance never reflected the ironwood receive (got {balance}, want ~{expected})" + ); + zebrad + .generate_blocks(2) + .await + .expect("advance chain to confirm the receive"); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // 9. Orchard->Ironwood SEND. zecd now holds an ironwood (Orchard-pool V3) note. Spending it on + // this post-NU6.3 chain necessarily builds a V6 transaction whose Orchard payment + change + // land in the **ironwood** bundle (new Orchard V2 outputs are forbidden past NU6.3), so the + // send can only prove and broadcast if zecd's ironwood proof step (`create_ironwood_proof` + // with the `PostNu6_3` circuit) ran. A fresh Orchard receiver is the payee (ironwood shares + // the Orchard receiver - there is no distinct ironwood address). The successful broadcast is + // the proof the send path works end-to-end; we then confirm it and re-check zecd still holds + // ironwood value (the change is an ironwood note too). + let payee = zecd + .call("getnewaddress", json!([])) + .await + .expect("getnewaddress payee") + .as_str() + .expect("payee address string") + .to_string(); + let tip = zebrad + .rpc("getblockcount", json!([])) + .await + .expect("getblockcount") + .as_u64() + .expect("tip height"); + zecd.wait_until_synced(tip, FUND_TIMEOUT) + .await + .expect("zecd at tip before the ironwood send"); + // 8b. Pin the SEND INPUT pool. The wallet's entire spendable shielded set is the single ironwood + // note funded above - no Orchard V2 or Sapling notes exist - so the send below can only draw + // an ironwood input. Assert it explicitly, so this stays a genuine ironwood->ironwood send and + // would not silently degrade into an Orchard-V2 drain if the funding/routing ever regressed. + let pre_send = zecd + .call("listunspent", json!([0])) + .await + .expect("listunspent before send"); + let pre_send = pre_send.as_array().expect("listunspent array"); + assert!( + !pre_send.is_empty(), + "wallet holds a spendable note before the ironwood send" + ); + assert!( + pre_send.iter().all(|u| u["pool"] == "ironwood"), + "every spendable input before the send is an ironwood note (input pool pinned): {pre_send:?}" + ); + // A note can read spendable in `getbalance` a confirmation before note selection accepts it, so + // retry the send (a transient -6) while advancing the chain, exactly as the funded e2e does. + let deadline = Instant::now() + FUND_TIMEOUT; + let send_txid = loop { + match zecd.call("sendtoaddress", json!([payee, 0.3])).await { + Ok(txid) => break txid.as_str().expect("txid string").to_string(), + Err(e) => { + assert!( + Instant::now() < deadline, + "orchard->ironwood send never succeeded (last error: {e})" + ); + zebrad + .generate_blocks(2) + .await + .expect("advance chain for spendability"); + let tip = zebrad + .rpc("getblockcount", json!([])) + .await + .expect("getblockcount") + .as_u64() + .expect("tip height"); + let _ = zecd.wait_until_synced(tip, FUND_TIMEOUT).await; + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + }; + assert_eq!( + send_txid.len(), + 64, + "orchard->ironwood send returns a display-hex txid: {send_txid}" + ); + + // Confirm the send and verify it landed: it shows as an outgoing tx, and zecd still holds an + // ironwood note (the change - proof the V6/ironwood output side round-tripped through the scan). + zebrad + .generate_blocks(6) + .await + .expect("confirm the ironwood send"); + let deadline = Instant::now() + FUND_TIMEOUT; + loop { + let tip = zebrad + .rpc("getblockcount", json!([])) + .await + .expect("getblockcount") + .as_u64() + .expect("tip height"); + let _ = zecd.wait_until_synced(tip, FUND_TIMEOUT).await; + let txs = zecd + .call("listtransactions", json!([])) + .await + .expect("listtransactions"); + let sent = txs + .as_array() + .expect("listtransactions array") + .iter() + .any(|t| t["category"] == "send"); + let unspent = zecd + .call("listunspent", json!([0])) + .await + .expect("listunspent"); + let unspent = unspent.as_array().expect("listunspent array"); + // Change side: the wallet still holds ironwood value after the send. + let has_ironwood = unspent.iter().any(|u| u["pool"] == "ironwood"); + // Payment side: the 0.3 note paid to `payee` (a self-owned Orchard receiver) landed as an + // ironwood note. This is the recipient half of the ironwood->ironwood send - the fee is drawn + // from the change, so the payment output is exactly 0.3 and distinct from the ~0.7 change. + let has_ironwood_payment = unspent.iter().any(|u| { + u["pool"] == "ironwood" && (u["amount"].as_f64().unwrap_or(0.0) - 0.3).abs() < 0.001 + }); + if sent && has_ironwood && has_ironwood_payment { + break; + } + assert!( + Instant::now() < deadline, + "ironwood send never confirmed as an outgoing tx with an ironwood payment + change; \ + listtransactions send={sent}, has ironwood={has_ironwood}, \ + has ironwood 0.3 payment={has_ironwood_payment}" + ); + zebrad + .generate_blocks(2) + .await + .expect("advance chain to confirm the send"); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // 10. Anchor-retention regression guard (librustzcash#2554). On a post-NU6.3 chain, whenever the + // scanner processes a batch whose starting `from_state` height (or a checkpoint height within + // it) is a multiple of `ANCHOR_RETENTION_INTERVAL` (288), the ironwood shardtree retains that + // checkpoint as a durable anchor - an `add_retained_checkpoint` write into the + // `ironwood_tree_retained_checkpoints` table. That table did not exist before #2554 (only the + // Sapling/Orchard counterparts did), so scanning across a 288-boundary failed the whole batch + // with `no such table: ironwood_tree_retained_checkpoints`. Every regtest chain here otherwise + // tops out below 288, so this is the one place that drives zecd's scan across the boundary: + // sync exactly to height 288, then scan block 289 on its own - its batch `from_state` is height + // 288 (a 288-multiple), so `update_tree` retains the ironwood anchor at 288 regardless of + // shielded activity in that block. Without #2554 the scan wedges and `wait_until_synced` times + // out; with it, zecd scans cleanly across the interval. + const ANCHOR_RETENTION_INTERVAL: u64 = 288; + let pre_tip = zebrad + .rpc("getblockcount", json!([])) + .await + .expect("getblockcount") + .as_u64() + .expect("tip height"); + assert!( + pre_tip < ANCHOR_RETENTION_INTERVAL, + "guard assumes the chain sits below the first anchor-retention interval before it runs \ + (tip {pre_tip}); if the earlier flow now exceeds {ANCHOR_RETENTION_INTERVAL}, retarget this \ + to the next multiple" + ); + // Sync zecd to exactly the interval height, so the following block is scanned in its own batch. + zebrad + .generate_blocks((ANCHOR_RETENTION_INTERVAL - pre_tip) as u32) + .await + .expect("mine up to the anchor-retention interval"); + zecd.wait_until_synced(ANCHOR_RETENTION_INTERVAL, FUND_TIMEOUT) + .await + .expect("zecd syncs up to the anchor-retention interval"); + // One more block: zecd scans it with `from_state` at height 288, retaining the ironwood anchor. + zebrad + .generate_blocks(1) + .await + .expect("mine one block past the anchor-retention interval"); + zecd.wait_until_synced(ANCHOR_RETENTION_INTERVAL + 1, FUND_TIMEOUT) + .await + .expect( + "zecd scans across the anchor-retention interval without hitting the missing \ + ironwood_tree_retained_checkpoints table (librustzcash#2554)", + ); + // The boundary range committed: a read RPC still resolves against the scanned wallet. + zecd.call("getbalance", json!([])) + .await + .expect("getbalance after scanning past the anchor-retention interval"); +} + +/// Sapling->Ironwood send: prove zecd can spend a **Sapling** note and produce an **ironwood** output +/// past NU6.3. The wallet is funded with ONLY a Sapling note (the funder pays zecd's Sapling +/// receiver), so the send's single input pool is Sapling; paying a fresh Orchard receiver on a +/// post-NU6.3 chain routes the output into the ironwood bundle (a Sapling->ironwood turnstile, +/// permitted under the default privacy policy). Because the wallet held no Orchard/ironwood note +/// before the send, any ironwood note afterwards is proof the send itself minted it. +#[tokio::test] +async fn regtest_ironwood_sapling_send() { + if std::env::var("ZECD_REGTEST_IRONWOOD").is_err() { + eprintln!( + "SKIP regtest_ironwood_sapling_send: set ZECD_REGTEST_IRONWOOD=1 (plus the ironwood \ + ZEBRAD_BIN/LIGHTWALLETD_BIN/DEVTOOL_BIN and an ironwood-built ZECD_BIN) to run the \ + NU6.3 e2e. The harness still compiled and linked." + ); + return; + } + let (Some(zebrad_bin), Some(lwd_bin), Some(devtool_bin)) = ( + resolve_bin("ZEBRAD_BIN"), + resolve_bin("LIGHTWALLETD_BIN"), + resolve_bin("DEVTOOL_BIN"), + ) else { + panic!( + "ZECD_REGTEST_IRONWOOD=1 but ZEBRAD_BIN/LIGHTWALLETD_BIN/DEVTOOL_BIN are not all set" + ); + }; + + // Same NU6.3-active regtest bring-up + funder shield as the receive test. + let funder_taddr = + Funder::derive_transparent_address(&devtool_bin).expect("derive funder transparent addr"); + let mut zebrad = Zebrad::start_with_miner_ironwood(&zebrad_bin, &funder_taddr) + .await + .expect("start ironwood zebrad mining to the funder"); + zebrad + .generate_blocks(FUNDER_COINBASES) + .await + .expect("mine the funder's coinbases"); + zebrad + .restart_with_miner(TAIL_MINER_ADDRESS) + .await + .expect("restart zebrad mining to the throwaway address"); + zebrad + .generate_blocks(MATURITY_TAIL) + .await + .expect("mine the maturity tail"); + let lwd = Lightwalletd::start(&lwd_bin, zebrad.rpc_port) + .await + .expect("start lightwalletd"); + let funder = + Funder::init_ironwood(&devtool_bin, lwd.grpc_port).expect("initialise funding wallet"); + funder.sync(lwd.grpc_port).expect("funder sync (coinbase)"); + funder + .shield(lwd.grpc_port) + .expect("shield transparent coinbase"); + zebrad.generate_blocks(6).await.expect("confirm shield"); + funder.sync(lwd.grpc_port).expect("funder sync (shielded)"); + + // zecd with BOTH shielded pools enabled, so it can hand out a Sapling receiver and route Orchard + // outputs (which become ironwood past NU6.3). + let mut cfg = ZecdConfig::new(zebrad.rpc_port, pick_port().expect("pick zecd rpc port")); + cfg.pools = Some(( + vec!["sapling".into(), "orchard".into()], + vec!["sapling".into(), "orchard".into()], + )); + let zecd = Zecd::start(&cfg) + .await + .expect("start zecd with sapling+orchard against ironwood zebra"); + + // A Sapling-only receiver for zecd; the funder pays it, so zecd holds exactly one Sapling note. + let sapling_ua = zecd + .call("getnewaddress", json!(["", "sapling"])) + .await + .expect("getnewaddress sapling") + .as_str() + .expect("sapling address string") + .to_string(); + + let deadline = Instant::now() + FUND_TIMEOUT; + loop { + let peers = zecd + .call("getpeerinfo", json!([])) + .await + .expect("getpeerinfo"); + if peers + .as_array() + .and_then(|a| a.first()) + .is_some_and(|p| p["conn_state"] == "ready") + { + break; + } + assert!( + Instant::now() < deadline, + "zecd never reached ready: {peers}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // Fund zecd's Sapling receiver. The funder spends its (ironwood) notes to a Sapling recipient - + // a cross-pool payment that lands as a plain Sapling note in zecd. + funder + .send(lwd.grpc_port, &sapling_ua, FUND_ZATOSHIS) + .expect("fund zecd's Sapling receiver"); + zebrad + .generate_blocks(6) + .await + .expect("confirm sapling funding"); + + // Wait until zecd sees a spendable Sapling note and NO ironwood note yet. + let deadline = Instant::now() + FUND_TIMEOUT; + loop { + let unspent = zecd + .call("listunspent", json!([0])) + .await + .expect("listunspent"); + let arr = unspent.as_array().expect("listunspent array"); + let has_sapling = arr.iter().any(|u| u["pool"] == "sapling"); + let has_ironwood = arr.iter().any(|u| u["pool"] == "ironwood"); + assert!( + !has_ironwood, + "wallet must hold no ironwood note before the send: {unspent}" + ); + if has_sapling { + break; + } + assert!( + Instant::now() < deadline, + "zecd never recorded the Sapling note: {unspent}" + ); + zebrad.generate_blocks(2).await.expect("advance chain"); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // Sapling->Ironwood send: pay a fresh Orchard receiver. The only fundable input is the Sapling + // note, and the Orchard-pool output becomes an ironwood note past NU6.3. + let payee = zecd + .call("getnewaddress", json!(["", "orchard"])) + .await + .expect("getnewaddress orchard payee") + .as_str() + .expect("payee address string") + .to_string(); + let tip = zebrad + .rpc("getblockcount", json!([])) + .await + .expect("getblockcount") + .as_u64() + .expect("tip height"); + zecd.wait_until_synced(tip, FUND_TIMEOUT) + .await + .expect("zecd at tip before the sapling->ironwood send"); + let deadline = Instant::now() + FUND_TIMEOUT; + let send_txid = loop { + match zecd.call("sendtoaddress", json!([payee, 0.3])).await { + Ok(txid) => break txid.as_str().expect("txid string").to_string(), + Err(e) => { + assert!( + Instant::now() < deadline, + "sapling->ironwood send never succeeded (last error: {e})" + ); + zebrad + .generate_blocks(2) + .await + .expect("advance chain for spendability"); + let tip = zebrad + .rpc("getblockcount", json!([])) + .await + .expect("getblockcount") + .as_u64() + .expect("tip height"); + let _ = zecd.wait_until_synced(tip, FUND_TIMEOUT).await; + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + }; + assert_eq!( + send_txid.len(), + 64, + "sapling->ironwood send returns a display-hex txid: {send_txid}" + ); + + // Confirm: the wallet, which held only a Sapling note, now holds an ironwood note - the output + // the Sapling spend minted past NU6.3. + zebrad + .generate_blocks(6) + .await + .expect("confirm sapling->ironwood send"); + let deadline = Instant::now() + FUND_TIMEOUT; + loop { + let tip = zebrad + .rpc("getblockcount", json!([])) + .await + .expect("getblockcount") + .as_u64() + .expect("tip height"); + let _ = zecd.wait_until_synced(tip, FUND_TIMEOUT).await; + let unspent = zecd + .call("listunspent", json!([0])) + .await + .expect("listunspent"); + if unspent + .as_array() + .expect("listunspent array") + .iter() + .any(|u| u["pool"] == "ironwood") + { + break; + } + assert!( + Instant::now() < deadline, + "the Sapling spend never produced an ironwood output: {unspent}" + ); + zebrad + .generate_blocks(2) + .await + .expect("advance chain to confirm the send"); + tokio::time::sleep(Duration::from_secs(2)).await; + } +} + +/// Ironwood receive **memo**: the funder attaches a ZIP-302 text memo to a post-NU6.3 send, which +/// auto-routes to an ironwood output, so zecd receives an ironwood note carrying a memo. This is the +/// end-to-end proof of the Ironwood-domain memo decryption path - `decrypt_transaction` decrypting +/// the Ironwood bundle (librustzcash `dw/ironwood-scan-model`, "Decrypt Ironwood outputs with the +/// Ironwood domain"). Compact blocks carry no memos, so surfacing the memo on the *mined* receive +/// exercises that decryption via the mempool full-tx store and/or the enhancement backfill; the +/// existing tier only proved memos for the Orchard/Sapling domains (`regtest_funded.rs`), never the +/// Ironwood one. Asserts the memo (`memoStr` text + `memo` hex) on a note confirmed `pool == +/// "ironwood"`. +#[tokio::test] +async fn regtest_ironwood_receive_memo() { + if std::env::var("ZECD_REGTEST_IRONWOOD").is_err() { + eprintln!( + "SKIP regtest_ironwood_receive_memo: set ZECD_REGTEST_IRONWOOD=1 (plus the ironwood \ + ZEBRAD_BIN/LIGHTWALLETD_BIN/DEVTOOL_BIN and an ironwood-built ZECD_BIN) to run the \ + NU6.3 e2e. The harness still compiled and linked." + ); + return; + } + let (Some(zebrad_bin), Some(lwd_bin), Some(devtool_bin)) = ( + resolve_bin("ZEBRAD_BIN"), + resolve_bin("LIGHTWALLETD_BIN"), + resolve_bin("DEVTOOL_BIN"), + ) else { + panic!( + "ZECD_REGTEST_IRONWOOD=1 but ZEBRAD_BIN/LIGHTWALLETD_BIN/DEVTOOL_BIN are not all set" + ); + }; + + /// The ZIP-302 text memo the funder attaches; zecd must surface it on the ironwood receive. + const RECEIVE_MEMO: &str = "ironwood memo e2e"; + + // 1-4. Bring up an NU6.3 chain, mine + mature the funder's coinbases, and shield into Orchard + // (identical to the other ironwood tests - see their comments). + let funder_taddr = + Funder::derive_transparent_address(&devtool_bin).expect("derive funder transparent addr"); + let mut zebrad = Zebrad::start_with_miner_ironwood(&zebrad_bin, &funder_taddr) + .await + .expect("start ironwood zebrad mining to the funder"); + zebrad + .generate_blocks(FUNDER_COINBASES) + .await + .expect("mine the funder's coinbases"); + zebrad + .restart_with_miner(TAIL_MINER_ADDRESS) + .await + .expect("restart zebrad mining to the throwaway address"); + zebrad + .generate_blocks(MATURITY_TAIL) + .await + .expect("mine the maturity tail"); + let lwd = Lightwalletd::start(&lwd_bin, zebrad.rpc_port) + .await + .expect("start lightwalletd"); + let funder = + Funder::init_ironwood(&devtool_bin, lwd.grpc_port).expect("initialise funding wallet"); + funder.sync(lwd.grpc_port).expect("funder sync (coinbase)"); + funder + .shield(lwd.grpc_port) + .expect("shield transparent coinbase into Orchard"); + zebrad.generate_blocks(6).await.expect("confirm shield"); + funder.sync(lwd.grpc_port).expect("funder sync (shielded)"); + + // 5-6. Start zecd (ironwood compiled unconditionally) and wait until it is caught up. + let cfg = ZecdConfig::new(zebrad.rpc_port, pick_port().expect("pick zecd rpc port")); + let zecd = Zecd::start(&cfg) + .await + .expect("start zecd against ironwood regtest zebra"); + let zecd_ua = zecd + .call("getnewaddress", json!([])) + .await + .expect("getnewaddress"); + let zecd_ua = zecd_ua.as_str().expect("address string").to_string(); + // `getnewaddress` must NEVER return an empty response, and must stay a valid unified address + // even with NU6.3/ironwood active on this chain and an Orchard-only receiver config: ironwood + // has no distinct UA receiver (it reuses the Orchard receiver - a post-NU6.3 Orchard-address + // output is simply an ironwood V3 *note*), so the address derives from the account UFVK exactly + // as it does pre-NU6.3. A `None`/failed derivation would surface as a JSON-RPC *error*, never an + // empty success string. Assert it here so a regression fails at the call with a clear message + // rather than later as a downstream funding timeout. (`u`/`utest`/`uregtest` all start with `u`.) + assert!( + !zecd_ua.is_empty(), + "getnewaddress returned an empty address on an NU6.3-active chain (Orchard-only config)" + ); + assert!( + zecd_ua.starts_with('u'), + "getnewaddress returned a non-unified address on an NU6.3-active chain: {zecd_ua:?}" + ); + let deadline = Instant::now() + FUND_TIMEOUT; + loop { + let peers = zecd + .call("getpeerinfo", json!([])) + .await + .expect("getpeerinfo"); + if peers + .as_array() + .and_then(|a| a.first()) + .is_some_and(|p| p["conn_state"] == "ready") + { + break; + } + assert!( + Instant::now() < deadline, + "zecd never reached conn_state ready before funding: {peers}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // 7. Fund zecd WITH A MEMO. Post-NU6.3 the funder's `wallet send` auto-routes the Orchard + // payment into an ironwood output, so zecd receives an ironwood note carrying the memo. + funder + .send_with_memo(lwd.grpc_port, &zecd_ua, FUND_ZATOSHIS, Some(RECEIVE_MEMO)) + .expect("send funds (with a memo) to zecd (auto-routed to ironwood post-NU6.3)"); + zebrad + .generate_blocks(6) + .await + .expect("confirm the memo funding send"); + + // 8. zecd scans the ironwood note and surfaces the memo. Poll (mining to drive confirmation and + // any enhancement backfill) until BOTH hold: an ironwood note is present in listunspent, and + // the receive in listtransactions decodes the funder's text memo. A memo on the received + // ironwood output can only appear if `decrypt_transaction` decrypted the Ironwood bundle. + let memo_hex: String = RECEIVE_MEMO.bytes().map(|b| format!("{b:02x}")).collect(); + let deadline = Instant::now() + FUND_TIMEOUT; + let receive = loop { + let tip = zebrad + .rpc("getblockcount", json!([])) + .await + .expect("getblockcount") + .as_u64() + .expect("tip height"); + let _ = zecd.wait_until_synced(tip, FUND_TIMEOUT).await; + + let unspent = zecd + .call("listunspent", json!([0])) + .await + .expect("listunspent"); + let has_ironwood = unspent + .as_array() + .expect("listunspent array") + .iter() + .any(|u| u["pool"] == "ironwood"); + + let txs = zecd + .call("listtransactions", json!([])) + .await + .expect("listtransactions"); + let receive = txs + .as_array() + .expect("listtransactions array") + .iter() + .find(|t| t["category"] == "receive" && t["memoStr"].as_str() == Some(RECEIVE_MEMO)); + + if has_ironwood { + if let Some(receive) = receive { + break receive.clone(); + } + } + assert!( + Instant::now() < deadline, + "zecd never surfaced the ironwood receive with its memo; has_ironwood={has_ironwood}, \ + listtransactions={txs}" + ); + zebrad + .generate_blocks(2) + .await + .expect("advance chain to confirm / enhance the memo receive"); + tokio::time::sleep(Duration::from_secs(2)).await; + }; + + // The received ironwood output carries the memo: decoded text in `memoStr`, raw ZIP-302 bytes + // in `memo` (zcashd's z_viewtransaction field names). + assert_eq!( + receive["memoStr"].as_str(), + Some(RECEIVE_MEMO), + "the ironwood receive decodes the funder's text memo: {receive}" + ); + assert_eq!( + receive["memo"].as_str(), + Some(memo_hex.as_str()), + "the ironwood receive carries the memo hex: {receive}" + ); + + // gettransaction on the receive surfaces the same memo on its receive detail. + let recv_txid = receive["txid"] + .as_str() + .expect("the receive carries a txid") + .to_string(); + let gt_recv = zecd + .call("gettransaction", json!([recv_txid])) + .await + .expect("gettransaction on the ironwood memo receive"); + let recv_detail = gt_recv["details"] + .as_array() + .expect("gettransaction details") + .iter() + .find(|d| d["category"] == "receive") + .cloned() + .unwrap_or_else(|| panic!("gettransaction details carry the receive: {gt_recv}")); + assert_eq!( + recv_detail["memoStr"].as_str(), + Some(RECEIVE_MEMO), + "gettransaction's ironwood receive detail decodes the memo: {recv_detail}" + ); +} diff --git a/regtest-harness/tests/regtest_sapling.rs b/regtest-harness/tests/regtest_sapling.rs index 1484db9..a333314 100644 --- a/regtest-harness/tests/regtest_sapling.rs +++ b/regtest-harness/tests/regtest_sapling.rs @@ -1,19 +1,25 @@ //! Multi-pool (Sapling + Orchard) regtest end-to-end: prove that a wallet configured with both //! shielded pools generates Sapling-bearing addresses, holds funds in **both** pools at once, and -//! reports those funds correctly across every balance/history RPC - then spends across pools. +//! reports those funds correctly across every balance/history RPC - then spends via the turnstile. //! //! Setup mirrors `regtest_funded.rs`: mine a transparent coinbase to the funder, mature it, shield //! it, then send shielded funds to zecd. The difference is that zecd is configured with //! `[pools] enabled = ["sapling", "orchard"]`, and we fund **two** receivers - a Sapling-only one -//! and an Orchard-only one - so the wallet ends up holding one Sapling note and one Orchard note. -//! That lets us assert that getbalance / getbalances / getwalletinfo / listunspent / +//! (1 ZEC) and an Orchard-only one (2 ZEC) - so the wallet ends up holding one Sapling note and one +//! Orchard note. That lets us assert that getbalance / getbalances / getwalletinfo / listunspent / //! getreceivedbyaddress all aggregate across pools. //! //! The funder (zcash-devtool) spends its Orchard notes; sending to a Sapling-only receiver is an //! ordinary cross-pool transfer (devtool's `propose_transfer` takes no privacy policy - the same -//! call zecd uses when it sends Orchard→transparent in `regtest_funded`), so the value simply +//! call zecd uses when it sends Orchard->transparent in `regtest_funded`), so the value simply //! lands as a Sapling note. //! +//! NB: past the `dw/ironwood-scan-model` "select shielded inputs from a single pool group" change, +//! a single transaction never combines Orchard inputs with Sapling inputs - selection uses one +//! group (Orchard, or Sapling+Ironwood). So a payment isn't funded by draining both pools; the +//! Orchard receiver is funded to 2 ZEC (vs Sapling's 1 ZEC) so a 1.5-ZEC payment to the Sapling +//! receiver still builds - from the Orchard group - as an Orchard->Sapling *turnstile* (phase 8). +//! //! The final phase is the flagship **tri-pool mixed-recipient `sendmany`**: a single v5 transaction //! that carries a transparent output AND a Sapling output AND an Orchard output simultaneously, //! funded from the wallet's Orchard change - the exact path behind the PCZT prover proving Orchard @@ -215,10 +221,16 @@ async fn regtest_sapling_and_orchard_balances() { } tokio::time::sleep(Duration::from_secs(3)).await; - // Fund the ORCHARD receiver first, confirm it, and let the funder's change reconfirm so its - // next send has spendable notes. + // Fund the ORCHARD receiver first (with 2 ZEC), confirm it, and let the funder's change + // reconfirm so its next send has spendable notes. Orchard is funded to *twice* the Sapling + // amount on purpose: past the `dw/ironwood-scan-model` "single pool group" change, shielded + // inputs are selected from one of two mutually-exclusive groups (Orchard alone, or + // Sapling+Ironwood) - Orchard is never combined with Sapling to make up a shortfall. The + // asymmetric funding lets a 1.5-ZEC payment to the Sapling-only receiver still build (the + // Sapling group's 1 ZEC can't cover it, so selection falls to the 2-ZEC Orchard group, + // producing an Orchard->Sapling *turnstile*), which is what phase 8a/8b exercise. funder - .send(lwd.grpc_port, &orchard_ua, FUND_ZATOSHIS) + .send(lwd.grpc_port, &orchard_ua, 2 * FUND_ZATOSHIS) .expect("send to zecd's Orchard receiver"); zebrad .generate_blocks(12) @@ -254,8 +266,8 @@ async fn regtest_sapling_and_orchard_balances() { .await .expect("confirm Sapling send"); - // 7. Both notes confirmed: the wallet holds 2 ZEC across two pools. Assert EVERY balance RPC - // aggregates across Sapling + Orchard. + // 7. Both notes confirmed: the wallet holds 3 ZEC across two pools (2 Orchard + 1 Sapling). + // Assert EVERY balance RPC aggregates across Sapling + Orchard. let deadline = Instant::now() + FUND_TIMEOUT; loop { let bal = zecd @@ -264,12 +276,12 @@ async fn regtest_sapling_and_orchard_balances() { .ok() .and_then(|v| v.as_f64()) .unwrap_or(0.0); - if bal >= 2.0 { + if bal >= 3.0 { break; } assert!( Instant::now() < deadline, - "zecd did not reach the combined 2-ZEC balance within {FUND_TIMEOUT:?} (got {bal})" + "zecd did not reach the combined 3-ZEC balance within {FUND_TIMEOUT:?} (got {bal})" ); tokio::time::sleep(Duration::from_millis(500)).await; } @@ -281,7 +293,7 @@ async fn regtest_sapling_and_orchard_balances() { .expect("getbalance"); assert_eq!( getbalance.as_f64(), - Some(2.0), + Some(3.0), "getbalance sums both pools: {getbalance}" ); @@ -292,7 +304,7 @@ async fn regtest_sapling_and_orchard_balances() { .expect("getbalances"); assert_eq!( getbalances["mine"]["trusted"].as_f64(), - Some(2.0), + Some(3.0), "getbalances.mine.trusted sums both pools: {getbalances}" ); assert_eq!( @@ -308,7 +320,7 @@ async fn regtest_sapling_and_orchard_balances() { .expect("getwalletinfo"); assert_eq!( wi["balance"].as_f64(), - Some(2.0), + Some(3.0), "getwalletinfo.balance: {wi}" ); assert_eq!( @@ -328,7 +340,8 @@ async fn regtest_sapling_and_orchard_balances() { "getunconfirmedbalance is 0: {unconf}" ); - // listunspent: two notes (one per pool), summing to 2 ZEC. + // listunspent: two notes (one per pool - a 2-ZEC Orchard note and a 1-ZEC Sapling note), + // summing to 3 ZEC. let lu = zecd .call("listunspent", json!([])) .await @@ -341,8 +354,8 @@ async fn regtest_sapling_and_orchard_balances() { ); let total: f64 = notes.iter().filter_map(|n| n["amount"].as_f64()).sum(); assert!( - (total - 2.0).abs() < 1e-8, - "listunspent sums to 2 ZEC: {lu}" + (total - 3.0).abs() < 1e-8, + "listunspent sums to 3 ZEC: {lu}" ); // getreceivedbyaddress: each receiver credited exactly its 1 ZEC. @@ -361,8 +374,8 @@ async fn regtest_sapling_and_orchard_balances() { .expect("getreceivedbyaddress orchard"); assert_eq!( recv_orchard.as_f64(), - Some(1.0), - "Orchard UA credited 1 ZEC: {recv_orchard}" + Some(2.0), + "Orchard UA credited 2 ZEC: {recv_orchard}" ); // History carries both receives. @@ -378,7 +391,12 @@ async fn regtest_sapling_and_orchard_balances() { .count(); assert!(receives >= 2, "both receives show in history: {txs}"); - // 8. Spend across pools back to the funder. First the wallet must have *scanned* to the chain + // 8. Turnstile spends. Past the "single pool group" input-selection change, Orchard inputs are + // never combined with Sapling inputs in one transaction - so a payment isn't funded by + // draining both pools at once; instead selection picks one group (Orchard, preferred when the + // payment targets Orchard) and, when that group's pool differs from the recipient's, produces + // a turnstile output. Both sends below spend the 2-ZEC Orchard note. First the wallet must + // have *scanned* to the chain // tip - note witnesses plus full confirmations - not merely observed the notes at the tip. // `getbalance` reports spendability relative to the chain tip and can run ahead of the scan // on a slow runner, so wait for the scan to catch up before any spend (the other funded @@ -394,11 +412,12 @@ async fn regtest_sapling_and_orchard_balances() { .await .expect("zecd scans to the chain tip before spending"); - // 8a. FullPrivacy rejects a cross-pool (turnstile) send. Paying 1.5 ZEC to a Sapling-only - // recipient forces Orchard inputs (the Sapling note is only 1 ZEC), so the proposal spans - // both pools - which reveals the crossed amount and FullPrivacy forbids. This rejection - // happens on the *built proposal* inside the async send, so it surfaces via the - // operation's error (code -8), not synchronously. The default policy permits it (8b). + // 8a. FullPrivacy rejects a turnstile send. Paying 1.5 ZEC to a Sapling-only recipient can't be + // funded from the Sapling group (only 1 ZEC), so selection falls to the 2-ZEC Orchard group, + // producing an Orchard->Sapling turnstile output - which reveals the crossed amount and + // FullPrivacy forbids. This rejection happens on the *built proposal* inside the async send, + // so it surfaces via the operation's error (code -8), not synchronously. The default policy + // permits it (8b). let fp_opid = zecd .call( "z_sendmany", @@ -443,16 +462,20 @@ async fn regtest_sapling_and_orchard_balances() { } assert!( saw_full_privacy_reject, - "the FullPrivacy cross-pool send reached the failed state" + "the FullPrivacy turnstile send reached the failed state" ); - // 8b. The same cross-pool spend under the default policy succeeds (greedy input selection - // draws from both pools; change lands in Orchard, the strongest enabled pool). + // 8b. A 1.5-ZEC spend under the default policy succeeds, funded from the 2-ZEC Orchard group. + // Paying the funder's unified address (which has an Orchard receiver) keeps the payment in + // the Orchard pool - no turnstile - with the ~0.5-ZEC change landing back in Orchard (the + // strongest enabled pool), which phase 9's tri-pool send then draws on. (The default policy + // would also permit the turnstile 8a rejected; that permission is covered by the funded + // e2e's transparent-recipient sends.) let funder_ua = funder.unified_address().expect("funder unified address"); let txid = zecd .call("sendtoaddress", json!([funder_ua, 1.5])) .await - .expect("spend across pools"); + .expect("orchard-funded spend"); let txid = txid.as_str().expect("txid string").to_string(); assert_eq!(txid.len(), 64, "sendtoaddress returns a txid: {txid}"); diff --git a/scripts/conformance.py b/scripts/conformance.py index aaa0fc7..cedbd68 100644 --- a/scripts/conformance.py +++ b/scripts/conformance.py @@ -456,9 +456,13 @@ def ck(name, cond, detail=""): if lu: u = lu[0] for f in ("txid", "vout", "address", "amount", "confirmations", - "spendable", "solvable", "safe"): + "spendable", "solvable", "safe", "pool"): ck(f"utxo has {f}", f in u) ck("utxo amount is Decimal", isinstance(u["amount"], decimal.Decimal)) + # zecd extension: every note is labelled with its shielded pool. + ck("utxo pool is a known shielded pool", + all(e["pool"] in ("sapling", "orchard", "ironwood") for e in lu), + [e["pool"] for e in lu]) ck("listunspent include_unsafe=false only safe", all(e["safe"] for e in rpc.call("listunspent", 0, 9999999, [], False))) # A freshly generated address has no notes; the filter validates its entries. diff --git a/src/chain/mod.rs b/src/chain/mod.rs index a666977..5ed378e 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -16,7 +16,7 @@ use std::future::Future; use zcash_client_backend::proto::compact_formats::CompactBlock; use zcash_client_backend::proto::service; use zcash_protocol::consensus::BlockHeight; -use zcash_protocol::{ShieldedProtocol, TxId}; +use zcash_protocol::{ShieldedPool, TxId}; /// The chain tip as reported by the upstream. `hash` is in internal byte order (reverse of /// the familiar display hex); it may be empty if the upstream didn't report one. @@ -121,7 +121,7 @@ pub trait ChainSource: Send { ) -> impl Future> + Send; /// Stream the compact blocks for `start..=end` in order (lightwalletd `GetBlockRange`; - /// zebra `getblock` + local full-block→CompactBlock conversion). + /// zebra `getblock` + local full-block->CompactBlock conversion). /// /// When `include_transparent` is set, each streamed item also carries the block's transparent /// outputs (see [`CompactBlockStream::next`]) so the caller can discover transparent receives @@ -139,7 +139,7 @@ pub trait ChainSource: Send { /// `GetSubtreeRoots`; zebra `z_getsubtreesbyindex`). fn subtree_roots( &mut self, - protocol: ShieldedProtocol, + protocol: ShieldedPool, ) -> impl Future>> + Send; /// Upstream identity/liveness (lightwalletd `GetLightdInfo`; zebra `getblockchaininfo`). @@ -223,7 +223,7 @@ impl ChainSource for AnySource { async fn subtree_roots( &mut self, - protocol: ShieldedProtocol, + protocol: ShieldedPool, ) -> anyhow::Result> { match self { AnySource::Zebra(s) => s.subtree_roots(protocol).await, diff --git a/src/chain/zebra.rs b/src/chain/zebra.rs index 10b945a..be4eb24 100644 --- a/src/chain/zebra.rs +++ b/src/chain/zebra.rs @@ -5,7 +5,7 @@ //! | operation | zebrad JSON-RPC | //! |-----------------------|------------------------------------------------------------| //! | `latest_block` | `getblockchaininfo` (height + best hash) | -//! | `tree_state` | `z_gettreestate` (finalState hex → protobuf `TreeState`) | +//! | `tree_state` | `z_gettreestate` (finalState hex -> protobuf `TreeState`) | //! | `compact_block_range` | `getblock verbosity=0` (raw block, parsed + compacted | //! | | locally) + `getblock verbosity=1` (`trees` sizes) | //! | `subtree_roots` | `z_getsubtreesbyindex` | @@ -16,7 +16,7 @@ //! | | closes when `getbestblockhash` changes (lightwalletd | //! | | parity: stream-close is the actor's sync-now signal) | //! -//! The full-block→CompactBlock conversion ([`block_to_compact`]) is the part lightwalletd +//! The full-block->CompactBlock conversion ([`block_to_compact`]) is the part lightwalletd //! otherwise does for us: parse the raw block (`zcash_primitives::block::Block`), then per //! transaction extract the trial-decryption fields via librustzcash's own `From` impls //! (Sapling nullifier/cmu/epk + 52-byte ciphertext prefix; Orchard nullifier/cmx/epk + @@ -49,7 +49,7 @@ use zcash_client_backend::proto::compact_formats as pb; use zcash_client_backend::proto::service; use zcash_primitives::block::Block; use zcash_protocol::consensus::BlockHeight; -use zcash_protocol::{ShieldedProtocol, TxId}; +use zcash_protocol::{ShieldedPool, TxId}; use super::{ BroadcastOutcome, ChainSource, ChainTip, CompactBlockStream, FetchedTx, MempoolStream, @@ -175,7 +175,7 @@ pub struct CleartextPolicy { /// Treat private / non-globally-routable addresses (RFC1918 `10/172.16/192.168`, link-local, /// CGNAT `100.64/10`, IPv6 unique-local `fc00::/7` and link-local `fe80::/10`) as "local", so /// a credentialed connect to a container/LAN zebra is allowed without an override. Default - /// `true` (matches the self-hosted `zebra → zecd` Docker/LAN norm); set `false` for a strict + /// `true` (matches the self-hosted `zebra -> zecd` Docker/LAN norm); set `false` for a strict /// loopback-only posture. `[backend] rfc1918_is_local`. pub rfc1918_is_local: bool, /// Allow credentials over cleartext to *any* host, including globally-routable ones - the @@ -393,7 +393,7 @@ impl ZebraClient { /// The note-commitment-tree sizes after this block, from `getblock verbosity=1`'s /// `trees` field - the same place lightwalletd gets `chain_metadata` from. Queried by /// hash (not height) so the sizes can't race a reorg against the raw-block fetch. - async fn block_trees(&self, hash: &str) -> anyhow::Result<(u32, u32)> { + async fn block_trees(&self, hash: &str) -> anyhow::Result<(u32, u32, u32)> { let verbose: VerboseBlock = self.call_as("getblock", json!([hash, 1])).await?; let trees = verbose.trees.unwrap_or_default(); // A pool that isn't active yet at this block is reported by zebra as an empty object @@ -408,6 +408,7 @@ impl ZebraClient { Ok(( trees.sapling.and_then(|t| t.size).unwrap_or(0), trees.orchard.and_then(|t| t.size).unwrap_or(0), + trees.ironwood.and_then(|t| t.size).unwrap_or(0), )) } } @@ -431,6 +432,10 @@ struct Trees { sapling: Option, #[serde(default)] orchard: Option, + // Present only on the zebra ironwood branch (its `trees` gains an ironwood entry). Absent on + // stock zebra -> `None` -> size 0, which is correct pre-activation and in the default build. + #[serde(default)] + ironwood: Option, } #[derive(Debug, Deserialize)] @@ -448,6 +453,11 @@ struct TreeStateReply { sapling: Option, #[serde(default)] orchard: Option, + // Present only when the upstream serves an ironwood treestate (zebra's ironwood branch). Stock + // zebra omits the field, so `Option` + `unwrap_or_default()` yields an empty tree (= pool not + // active) - safe to deserialize against either backend. + #[serde(default)] + ironwood: Option, } #[derive(Debug, Default, Deserialize)] @@ -521,6 +531,28 @@ impl ZebraSource { }) } + /// Fetch all completed note-commitment-subtree roots for a zebra pool label + /// (`"sapling"`/`"orchard"`/`"ironwood"`) via `z_getsubtreesbyindex` from index 0. + async fn subtree_roots_for_pool(&mut self, pool: &str) -> anyhow::Result> { + // start_index 0, no limit: all completed subtrees (what lightwalletd serves). + let reply: SubtreesReply = self + .client + .call_as("z_getsubtreesbyindex", json!([pool, 0])) + .await?; + reply + .subtrees + .into_iter() + .map(|s| { + Ok(SubtreeRootInfo { + // Subtree roots are node hashes, not txids/block hashes: the hex is NOT + // byte-reversed (lightwalletd decodes it verbatim too). + root_hash: hex::decode(&s.root).context("decoding subtree root hex")?, + completing_height: s.end_height, + }) + }) + .collect() + } + #[cfg(test)] fn with_mempool_poll(mut self, poll: Duration) -> Self { self.mempool_poll = poll; @@ -567,6 +599,9 @@ impl ChainSource for ZebraSource { time: reply.time, sapling_tree: reply.sapling.unwrap_or_default().final_state(), orchard_tree: reply.orchard.unwrap_or_default().final_state(), + // Maps the upstream ironwood treestate when present (zebra's ironwood branch). Empty + // (= pool not active) against stock zebra and in the released-mode default build. + ironwood_tree: reply.ironwood.unwrap_or_default().final_state(), }) } @@ -587,29 +622,18 @@ impl ChainSource for ZebraSource { async fn subtree_roots( &mut self, - protocol: ShieldedProtocol, + protocol: ShieldedPool, ) -> anyhow::Result> { let pool = match protocol { - ShieldedProtocol::Sapling => "sapling", - ShieldedProtocol::Orchard => "orchard", + ShieldedPool::Sapling => "sapling", + ShieldedPool::Orchard => "orchard", + // `ShieldedPool` is non-exhaustive at the librustzcash level now that Ironwood is a + // variant. Ironwood subtree roots are not pre-seeded (the scan-model upstream builds the + // Ironwood tree during `put_blocks`), so nothing calls this arm with Ironwood today; map + // the pool name for completeness. + ShieldedPool::Ironwood => "ironwood", }; - // start_index 0, no limit: all completed subtrees (what lightwalletd serves). - let reply: SubtreesReply = self - .client - .call_as("z_getsubtreesbyindex", json!([pool, 0])) - .await?; - reply - .subtrees - .into_iter() - .map(|s| { - Ok(SubtreeRootInfo { - // Subtree roots are node hashes, not txids/block hashes: the hex is NOT - // byte-reversed (lightwalletd decodes it verbatim too). - root_hash: hex::decode(&s.root).context("decoding subtree root hex")?, - completing_height: s.end_height, - }) - }) - .collect() + self.subtree_roots_for_pool(pool).await } async fn server_info(&mut self) -> anyhow::Result { @@ -790,7 +814,7 @@ impl ZebraBlockStream { u16::MAX, ); } - let (sapling_size, orchard_size) = self + let (sapling_size, orchard_size, ironwood_size) = self .client .block_trees(&block.header().hash().to_string()) .await?; @@ -802,11 +826,9 @@ impl ZebraBlockStream { } else { Vec::new() }; + let compact = block_to_compact(&block, sapling_size, orchard_size, ironwood_size); self.next += 1; - Ok(Some(( - block_to_compact(&block, sapling_size, orchard_size), - transparent, - ))) + Ok(Some((compact, transparent))) } } @@ -840,7 +862,12 @@ fn block_transparent_outputs(block: &Block, height: u32) -> Vec pb::CompactBlock { +pub fn block_to_compact( + block: &Block, + sapling_size: u32, + orchard_size: u32, + ironwood_size: u32, +) -> pb::CompactBlock { let header = block.header(); let vtx = block .vtx() @@ -862,12 +889,20 @@ pub fn block_to_compact(block: &Block, sapling_size: u32, orchard_size: u32) -> .orchard_bundle() .map(|b| b.actions().iter().map(Into::into).collect()) .unwrap_or_default(), + // Ironwood (V6) bundle actions reuse Orchard's action/note crypto, so the same + // `CompactOrchardAction` conversion applies and the scanner reads them. Upstream now + // exposes `Transaction::ironwood_bundle()` and the `CompactTx.ironwood_actions` proto + // field unconditionally; pre-activation blocks carry no ironwood bundle, so the default + // build simply emits an empty list (behavior unchanged) until NU6.3 is active. + ironwood_actions: tx + .ironwood_bundle() + .map(|b| b.actions().iter().map(Into::into).collect()) + .unwrap_or_default(), vin: vec![], vout: vec![], }) .collect(); pb::CompactBlock { - proto_version: 0, height: u64::from(u32::from(block.claimed_height())), hash: header.hash().0.to_vec(), prev_hash: header.prev_block.0.to_vec(), @@ -877,6 +912,9 @@ pub fn block_to_compact(block: &Block, sapling_size: u32, orchard_size: u32) -> chain_metadata: Some(pb::ChainMetadata { sapling_commitment_tree_size: sapling_size, orchard_commitment_tree_size: orchard_size, + // From the block's ironwood tree size (the zebra ironwood branch's `trees` ironwood + // entry). 0 pre-activation and on stock zebra - the only case the default build sees. + ironwood_commitment_tree_size: ironwood_size, }), } } @@ -1167,9 +1205,9 @@ mod tests { subtrees: Value, mempool: Vec, raw_txs: HashMap, - /// `getaddresstxids` responses keyed by transparent address → display-hex txids. + /// `getaddresstxids` responses keyed by transparent address -> display-hex txids. addr_txids: HashMap>, - /// `getaddressutxos` responses keyed by transparent address → UTXO objects + /// `getaddressutxos` responses keyed by transparent address -> UTXO objects /// (`{txid, outputIndex, script, satoshis, height}`). addr_utxos: HashMap>, /// `Err((code, message))` makes `sendrawtransaction` reject. @@ -1410,8 +1448,8 @@ mod tests { /// Regression guard for the tip-advance step of the -25 (already-expired) send fix: a /// spend's expiry height is derived from the wallet DB's chain tip, so before building one - /// the actor pulls the upstream's *real* tip into the DB and scans up to it (`do_send` → - /// `sync_to_tip_for_send` → `fetch_and_store_chain_tip`, then a catch-up scan). This covers + /// the actor pulls the upstream's *real* tip into the DB and scans up to it (`do_send` -> + /// `sync_to_tip_for_send` -> `fetch_and_store_chain_tip`, then a catch-up scan). This covers /// the tip-advance half: the DB starts at a stale height far below the upstream's, and one /// call must advance it to the upstream tip (the actor then scans the gap so the anchor is /// valid too). If the tip didn't advance, a send would expire against the stale height. @@ -1628,7 +1666,7 @@ mod tests { ], }); let mut src = source_for(fake).await; - let roots = src.subtree_roots(ShieldedProtocol::Orchard).await.unwrap(); + let roots = src.subtree_roots(ShieldedPool::Orchard).await.unwrap(); assert_eq!(roots.len(), 2); // Subtree roots are node hashes: decoded verbatim, never byte-reversed. assert_eq!(roots[0].root_hash, vec![0x0a, 0x0b, 0x0c]); diff --git a/src/config.rs b/src/config.rs index 6612efb..ec4952a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -272,7 +272,7 @@ pub struct BackendConfig { /// Treat private / non-globally-routable ranges (RFC1918, link-local, CGNAT, IPv6 unique-local /// and link-local) as "local" for the cleartext-credential gate, so a credentialed connect to /// a container/LAN zebra is allowed without an override. Default `true` (the self-hosted - /// `zebra → zecd` Docker/LAN norm); set `false` for a strict loopback-only posture. + /// `zebra -> zecd` Docker/LAN norm); set `false` for a strict loopback-only posture. pub rfc1918_is_local: bool, /// Escape hatch for the cleartext-credential gate: allow the (plaintext) zebra connection to /// carry RPC credentials to *any* host, including globally-routable ones. Off by default; the @@ -446,13 +446,13 @@ impl SendPrivacy { } } - /// Whether this policy permits paying a transparent recipient, which reveals both the - /// recipient and the amount on-chain. `AllowRevealedRecipients` and the strictly more - /// permissive `AllowFullyTransparent` do; `FullPrivacy` and `AllowRevealedAmounts` reject a - /// transparent recipient (the latter opts into revealed *amounts* only). NB - /// `AllowFullyTransparent` must be included: it is the top rung of the ladder, so anything a - /// lower rung permits it must permit too - omitting it makes `build_payment` reject the very - /// t->t sends the policy exists to allow. + /// recipient and the amount on-chain. `AllowRevealedRecipients` (pay a transparent recipient + /// from shielded notes) and the strictly-more-permissive `AllowFullyTransparent` (additionally + /// fund it from transparent UTXOs with kept-transparent change) both do; `FullPrivacy` and + /// `AllowRevealedAmounts` reject a transparent recipient (the latter opts into revealed + /// *amounts* only). Omitting `AllowFullyTransparent` here would make the fully-transparent send + /// path unreachable - the `build_payment` pre-check would `-8`-reject the recipient before the + /// t->t spend could run. pub fn allows_transparent_recipient(self) -> bool { matches!( self, @@ -1521,7 +1521,7 @@ mod tests { #[test] fn orchard_action_limit_defaults_and_parses() { - // Absent → the Zallet-matching default of 50. + // Absent -> the Zallet-matching default of 50. assert_eq!(SpendConfig::default().orchard_action_limit, 50); assert_eq!(DEFAULT_ORCHARD_ACTION_LIMIT, 50); // Explicit value (including 0, which disables the cap) round-trips. @@ -1533,7 +1533,7 @@ mod tests { #[test] fn pipeline_proving_defaults_off_and_parses() { - // Absent → off (the inline send path, byte-identical to today's behaviour). + // Absent -> off (the inline send path, byte-identical to today's behaviour). assert!(!SpendConfig::default().pipeline_proving); // Explicit value round-trips both ways. let f: SpendFile = toml::from_str("pipeline_proving = true").unwrap(); @@ -1610,7 +1610,7 @@ mod tests { assert_eq!(f.connect_timeout_secs, Some(5)); assert_eq!(f.reconnect_base_secs, Some(2)); assert_eq!(f.reconnect_max_secs, Some(30)); - // The cleartext-gate knobs default to unset (→ rfc1918_is_local true, remote override + // The cleartext-gate knobs default to unset (-> rfc1918_is_local true, remote override // false) and parse when present. assert_eq!(f.rfc1918_is_local, None); assert_eq!(f.allow_remote_cleartext, None); @@ -1743,7 +1743,7 @@ mod tests { let cfg = AppConfig::resolve(&cli).unwrap(); assert_eq!(cfg.sync.interval_secs, 45); - // Absent section → the 20s default. + // Absent section -> the 20s default. std::fs::write(&conf, "datadir = \"/tmp/zecd-x\"\n").unwrap(); let cli = Cli::parse_from(["zecd", "--conf", conf.to_str().unwrap()]); let cfg = AppConfig::resolve(&cli).unwrap(); diff --git a/src/network.rs b/src/network.rs index df7c7ed..4a581ef 100644 --- a/src/network.rs +++ b/src/network.rs @@ -84,6 +84,17 @@ pub fn regtest() -> ZNetwork { // live. This must match the regtest chain the harness/zebra run (regtest-harness's // NU6_2_ACTIVATION_HEIGHT) so zecd commits transactions to the right consensus branch id. let nu62 = Some(BlockHeight::from_u32(4)); + // NU6.3 (ironwood) activation on the regtest chain is opt-in via `ZECD_REGTEST_NU63_HEIGHT`. + // Ironwood is always compiled now, so the *code* is unconditional; only the regtest activation + // height is a knob: the ironwood harness configures zebra with NU6.3 at that height (8) and sets + // this env var so zecd commits to the matching consensus branch id, while the standard harness + // leaves it unset (no NU6.3) to match its stock zebra. (Real networks get their heights from the + // pinned protocol - NU6.3 is unset on mainnet and 4_134_000 on testnet - so this only affects + // regtest.) An unset/unparseable value means no NU6.3, exactly like the old default build. + let nu63 = std::env::var("ZECD_REGTEST_NU63_HEIGHT") + .ok() + .and_then(|s| s.parse::().ok()) + .map(BlockHeight::from_u32); ZNetwork::Regtest(LocalNetwork { overwinter: h, sapling: h, @@ -94,6 +105,7 @@ pub fn regtest() -> ZNetwork { nu6: h, nu6_1: nu62, nu6_2: nu62, + nu6_3: nu63, #[cfg(zcash_unstable = "nu7")] nu7: nu62, #[cfg(zcash_unstable = "zfuture")] diff --git a/src/pools.rs b/src/pools.rs index f66aaa8..5ecbe78 100644 --- a/src/pools.rs +++ b/src/pools.rs @@ -5,15 +5,21 @@ //! Addresses it hands out should include (`default_receivers`). A default receiver may never name //! a pool that isn't enabled - that's a configuration error, caught at parse time. //! -//! The [`Pool`] enum is deliberately a zecd-local type rather than `zcash_protocol::ShieldedProtocol` -//! so that the upcoming **Ironwood** pool can be added as a single new variant: once -//! `ShieldedProtocol::Ironwood` exists upstream, add `Pool::Ironwood`, fill in its mappings here, -//! and the compiler's exhaustiveness checks will flag every site that needs a new arm. +//! The [`Pool`] enum is a zecd-local type rather than `zcash_protocol::ShieldedPool`, and note +//! that **Ironwood (NU6.3) is NOT a third [`Pool`] here** - even though upstream `ShieldedPool` now +//! *does* carry an `Ironwood` variant. Upstream models ironwood as **Orchard "V3" notes**: it +//! reuses Orchard's keys, addresses, and note cryptography, so there is no ironwood UA receiver +//! typecode. Ironwood notes are *received at ordinary Orchard addresses*; the Orchard/ironwood +//! distinction lives at the **transaction-bundle / note-version** level (a separate ironwood bundle +//! in V6 transactions). So ironwood is a *balance + spend* concern, not an +//! address-generation/receiver concern - it is surfaced in `wallet/read.rs` (balances), the +//! `v_tx_outputs.output_pool` code 4 (`wallet_methods::pool_name`), and the V6 spend path +//! (`wallet/actor.rs`), **not** by adding a variant to this enum. Keep `Pool` = {Sapling, Orchard}. use std::fmt; use zcash_keys::keys::{ReceiverRequirement, UnifiedAddressRequest}; -use zcash_protocol::{PoolType, ShieldedProtocol}; +use zcash_protocol::{PoolType, ShieldedPool}; /// A value pool that a zecd wallet can receive into and spend from. /// @@ -27,7 +33,8 @@ pub enum Pool { Transparent, Sapling, Orchard, - // future: Ironwood - add the variant here, then fill in every `match self` below. + // NB: Ironwood is deliberately NOT a variant - it is received at Orchard addresses and handled + // as a balance/spend dimension (an Orchard V3 note), not a UA receiver. See the module doc. } impl Pool { @@ -59,11 +66,11 @@ impl Pool { } /// The librustzcash shielded-protocol identifier for this pool, or `None` for transparent. - pub fn shielded_protocol(&self) -> Option { + pub fn shielded_protocol(&self) -> Option { match self { Pool::Transparent => None, - Pool::Sapling => Some(ShieldedProtocol::Sapling), - Pool::Orchard => Some(ShieldedProtocol::Orchard), + Pool::Sapling => Some(ShieldedPool::Sapling), + Pool::Orchard => Some(ShieldedPool::Orchard), } } @@ -140,7 +147,7 @@ impl PoolSet { Ok(Self { pools: ordered }) } - /// Parse a list of config tokens into a validated set (unknown name → error, empty → error). + /// Parse a list of config tokens into a validated set (unknown name -> error, empty -> error). pub fn parse>(tokens: &[S]) -> anyhow::Result { if tokens.is_empty() { anyhow::bail!("at least one shielded pool is required"); @@ -194,17 +201,18 @@ impl PoolSet { } /// The pool to receive change into when spending. Prefer Orchard (the strongest pool) when - /// enabled, else the first enabled pool. (When Ironwood lands, revisit this precedence.) - pub fn change_pool(&self) -> ShieldedProtocol { + /// enabled, else the first enabled pool. (Ironwood change is an Orchard-V3 note, so it rides + /// the Orchard arm here - there is no separate ironwood change pool.) + pub fn change_pool(&self) -> ShieldedPool { if self.contains(Pool::Orchard) { - ShieldedProtocol::Orchard + ShieldedPool::Orchard } else { // Non-empty and shielded-only by construction; fall back to the first enabled pool. self.pools .first() .copied() .and_then(|p| p.shielded_protocol()) - .unwrap_or(ShieldedProtocol::Orchard) + .unwrap_or(ShieldedPool::Orchard) } } } @@ -296,15 +304,15 @@ mod tests { PoolSet::parse(&["sapling", "orchard"]) .unwrap() .change_pool(), - ShieldedProtocol::Orchard + ShieldedPool::Orchard ); assert_eq!( PoolSet::single(Pool::Orchard).change_pool(), - ShieldedProtocol::Orchard + ShieldedPool::Orchard ); assert_eq!( PoolSet::single(Pool::Sapling).change_pool(), - ShieldedProtocol::Sapling + ShieldedPool::Sapling ); } } diff --git a/src/rpc/network.rs b/src/rpc/network.rs index e56e521..c6d4aba 100644 --- a/src/rpc/network.rs +++ b/src/rpc/network.rs @@ -17,9 +17,14 @@ fn connected(state: &AppState) -> bool { /// zecd's own version in Bitcoin Core's numeric encoding (major*10000 + minor*100 + patch), /// derived from Cargo.toml so it can't drift from the crate version. fn version_number() -> u64 { - let mut parts = env!("CARGO_PKG_VERSION") - .split('.') - .map(|p| p.parse::().unwrap_or(0)); + // Each component's leading digit run, so a pre-release suffix (e.g. "0-rc0") still encodes. + let mut parts = env!("CARGO_PKG_VERSION").split('.').map(|p| { + p.split(|c: char| !c.is_ascii_digit()) + .next() + .unwrap_or("") + .parse::() + .unwrap_or(0) + }); let major = parts.next().unwrap_or(0); let minor = parts.next().unwrap_or(0); let patch = parts.next().unwrap_or(0); @@ -89,15 +94,17 @@ mod tests { fn version_number_encodes_cargo_version() { let v = super::version_number(); assert!(v > 0, "version must encode to a nonzero number"); - // 0.1.0 -> 100, 1.2.3 -> 10203; sanity-check the arithmetic shape. - assert_eq!( - v % 100, - env!("CARGO_PKG_VERSION") - .split('.') - .nth(2) - .unwrap() - .parse::() - .unwrap() - ); + // 0.1.0 -> 100, 1.2.3 -> 10203; sanity-check the arithmetic shape. The patch component + // may carry a pre-release suffix (e.g. "0-rc0"), so compare against its leading digit run. + let patch: u64 = env!("CARGO_PKG_VERSION") + .split('.') + .nth(2) + .unwrap() + .split(|c: char| !c.is_ascii_digit()) + .next() + .unwrap() + .parse() + .unwrap(); + assert_eq!(v % 100, patch); } } diff --git a/src/rpc/wallet_methods.rs b/src/rpc/wallet_methods.rs index 9b7af57..e66585e 100644 --- a/src/rpc/wallet_methods.rs +++ b/src/rpc/wallet_methods.rs @@ -49,7 +49,7 @@ pub(crate) fn listwallets(state: &AppState) -> Result { /// /// `address_type` doubles as a per-call receiver override (zallet's `receiver_types`, expressed as /// a single string for Bitcoin-RPC conformance): -/// - empty / `"unified"` / `"default"` → the wallet's configured `default_receivers`, +/// - empty / `"unified"` / `"default"` -> the wallet's configured `default_receivers`, /// - a single pool name (`"orchard"`, `"sapling"`), /// - a comma-separated list (`"sapling,orchard"`). /// @@ -820,12 +820,14 @@ pub(crate) fn listtransactions( } /// The shielded value pool name for a `v_tx_outputs.output_pool` code (zcashd's `pool` -/// field): 0 = transparent, 2 = Sapling, 3 = Orchard. +/// field): 0 = transparent, 2 = Sapling, 3 = Orchard, 4 = Ironwood (NU6.3, the +/// `ironwood_pool_code_views` migration in the valar fork tags ironwood outputs as code 4). fn pool_name(pool: i64) -> &'static str { match pool { 0 => "transparent", 2 => "sapling", 3 => "orchard", + 4 => "ironwood", _ => "unknown", } } @@ -980,11 +982,11 @@ fn received_by_address( /// Matching is whole-UA string equality, not receiver-level: round-tripping the exact value /// `getnewaddress` returned always works (and sums receipts across all its receivers, which /// share one diversifier index), but a re-encoding with a different receiver subset, or a -/// different UA that merely shares a receiver, is a different string → `-4`/no contribution. +/// different UA that merely shares a receiver, is a different string -> `-4`/no contribution. /// A *spliced* UA (receivers stapled together from different diversifier indices, or one of this /// wallet's receivers mixed with a stranger's) is caught earlier by [`read::classify_unified_receivers`] /// and rejected with `-5` rather than silently treated as foreign. (The sole sub-receiver case is -/// the bare-t-address→UA rewrite, inert here since zecd exposes no transparent receivers.) +/// the bare-t-address->UA rewrite, inert here since zecd exposes no transparent receivers.) pub(crate) fn getreceivedbyaddress( state: &AppState, wallet: Option<&str>, @@ -1327,6 +1329,9 @@ fn unspent_json( "spendable": true, "solvable": true, "safe": conf > 0 || n.trusted, + // Shielded-pool label (zecd extension; Bitcoin Core notes are all transparent): + // "sapling" / "orchard" / "ironwood". Lets a shielded client tell the pool apart. + "pool": pool_name(n.pool), }) }) .collect() @@ -1601,7 +1606,7 @@ fn privacy_from_policy(s: Option<&str>, default: SendPrivacy) -> Result `None` (all of the wallet's operations); otherwise an /// array of opid strings - a malformed opid or non-string element is `-8`. fn parse_opid_filter(req: &RpcRequest, i: usize) -> Result>, RpcError> { match req.param(i) { @@ -2174,6 +2179,7 @@ mod tests { mined_height: conf_height, trusted, address: address.map(str::to_string), + pool: 3, } } @@ -2207,6 +2213,25 @@ mod tests { assert!(r.iter().all(|e| e["address"] == json!("addr-a"))); } + #[test] + fn listunspent_surfaces_pool_label() { + let st = status(100); + let pooled = |pool: i64| read::UnspentNote { + txid: "cd".repeat(32), + vout: 0, + value: 10, + mined_height: Some(100), + trusted: true, + address: None, + pool, + }; + let notes = vec![pooled(2), pooled(3), pooled(4)]; + let r = unspent_json(¬es, &st, 1, 9_999_999, None, true); + assert_eq!(r[0]["pool"], json!("sapling")); + assert_eq!(r[1]["pool"], json!("orchard")); + assert_eq!(r[2]["pool"], json!("ironwood")); + } + #[test] fn listunspent_param_parsing() { // Depth params: strict typing, defaults on omission. @@ -2366,6 +2391,21 @@ mod tests { false ) .is_ok()); + + // AllowFullyTransparent is strictly more permissive than AllowRevealedRecipients, so it + // must ALSO accept a transparent recipient - otherwise the `build_payment` pre-check -8s + // the recipient and the fully-transparent (t->t) spend path is unreachable. (Regression + // guard: the edge-fixes merge left `allows_transparent_recipient` without this rung, which + // broke the regtest_transparent_t2t / _sendmany_t2t e2e.) + assert!(build_payment( + &net, + SendPrivacy::AllowFullyTransparent, + &taddr, + &json!(0.1), + None, + false + ) + .is_ok()); } #[test] diff --git a/src/sync/engine.rs b/src/sync/engine.rs index 956e597..4e032b7 100644 --- a/src/sync/engine.rs +++ b/src/sync/engine.rs @@ -41,11 +41,11 @@ use zcash_client_backend::data_api::{ }; use zcash_client_backend::wallet::WalletTransparentOutput; use zcash_client_sqlite::error::SqliteClientError; -use zcash_client_sqlite::{chain::BlockMeta, FsBlockDb, FsBlockDbError}; +use zcash_client_sqlite::{chain::BlockMeta, AccountUuid, FsBlockDb, FsBlockDbError}; use zcash_primitives::merkle_tree::HashSer; use zcash_protocol::consensus::BlockHeight; use zcash_protocol::value::Zatoshis; -use zcash_protocol::{ShieldedProtocol, TxId}; +use zcash_protocol::{ShieldedPool, TxId}; use zcash_transparent::address::TransparentAddress; use crate::chain::ChainSource; @@ -61,7 +61,7 @@ pub async fn update_subtree_roots( db_data: &mut WriteDb, ) -> anyhow::Result<()> { let sapling_roots: Vec> = client - .subtree_roots(ShieldedProtocol::Sapling) + .subtree_roots(ShieldedPool::Sapling) .await? .into_iter() .map(|root| { @@ -75,7 +75,7 @@ pub async fn update_subtree_roots( db_data.put_sapling_subtree_roots(0, &sapling_roots)?; let orchard_roots: Vec> = client - .subtree_roots(ShieldedProtocol::Orchard) + .subtree_roots(ShieldedPool::Orchard) .await? .into_iter() .map(|root| { @@ -88,6 +88,13 @@ pub async fn update_subtree_roots( .collect::>()?; db_data.put_orchard_subtree_roots(0, &orchard_roots)?; + // Ironwood has no separate subtree-root seeding pass: the `dw/ironwood-scan-model` upstream + // populates the Ironwood note-commitment shardtree directly during `put_blocks` (received + // Ironwood notes are stored as blocks are scanned), and it exposes no `put_ironwood_subtree_roots` + // API to pre-seed roots from `z_getsubtreesbyindex "ironwood"` the way Sapling/Orchard do. On + // the short regtest chain this is equivalent; a from-genesis scan builds the tree. If upstream + // later adds an Ironwood subtree-root writer, reinstate a pass here (gated on NU6.3 being active). + Ok(()) } @@ -105,14 +112,24 @@ pub fn owned_transparent_output( value_zat: u64, script: Vec, height: Option, -) -> Option { +) -> Option> { use zcash_transparent::address::Script; use zcash_transparent::bundle::{OutPoint, TxOut}; let value = Zatoshis::from_u64(value_zat).ok()?; let outpoint = OutPoint::new(*txid.as_ref(), index); let txout = TxOut::new(value, Script(zcash_script::script::Code(script))); - let output = - WalletTransparentOutput::from_parts(outpoint, txout, height.map(BlockHeight::from_u32))?; + // The valar fork's `from_parts` takes three extra args (recipient_account, + // recipient_key_scope, funding_account); a chain-discovered UTXO doesn't know them, and + // `put_received_transparent_utxo` re-derives the owning account, so `None` matches the + // released 3-arg behavior. `` pins the otherwise-unconstrained account type. + let output = WalletTransparentOutput::from_parts( + outpoint, + txout, + height.map(BlockHeight::from_u32), + None, + None, + None, + )?; addresses .contains(output.recipient_address()) .then_some(output) @@ -125,7 +142,7 @@ async fn download_blocks( db_cache: &mut FsBlockDb, scan_range: &ScanRange, transparent: Option<&HashSet>, -) -> anyhow::Result<(Vec, Vec)> { +) -> anyhow::Result<(Vec, Vec>)> { info!("[{name}] Fetching {scan_range}"); let mut stream = client .compact_block_range( @@ -200,7 +217,7 @@ async fn download_chain_state( /// worse - a later reorg's `with_blocks` pass would try to open those now-fileless rows and /// fail with `NotFound` before the rewind's `truncate_to_height` could run, so the intended /// in-place reorg recovery never completed. Because sync processes one batch per call -/// (download → scan → delete before the next), truncating to just below the batch's lowest +/// (download -> scan -> delete before the next), truncating to just below the batch's lowest /// height removes exactly this batch's rows, so the table never accumulates. fn delete_cached_blocks( name: &str, @@ -623,7 +640,6 @@ mod tests { ..Default::default() }; let cb = pb::CompactBlock { - proto_version: 0, height: u64::from(height), hash: hash.to_vec(), prev_hash: prev.to_vec(), @@ -633,6 +649,8 @@ mod tests { chain_metadata: Some(pb::ChainMetadata { sapling_commitment_tree_size: 0, orchard_commitment_tree_size: orchard_tree_size, + // Fabricated-chain test helper: no ironwood notes in these synthetic blocks. + ironwood_commitment_tree_size: 0, }), }; let meta = BlockMeta { @@ -651,11 +669,15 @@ mod tests { } fn chain_state(height: u32, hash: [u8; 32], orchard: &OrchardFrontier) -> ChainState { + // The pinned scan-model `ChainState::new` takes a 5th ironwood final-tree frontier (ironwood + // notes are tracked in a separate shardtree). Empty: the fabricated reorg-test chain has no + // ironwood notes. ChainState::new( BlockHeight::from_u32(height), BlockHash(hash), Frontier::empty(), orchard.clone(), + Frontier::empty(), ) } diff --git a/src/wallet/actor.rs b/src/wallet/actor.rs index 59785a5..45e278d 100644 --- a/src/wallet/actor.rs +++ b/src/wallet/actor.rs @@ -14,13 +14,14 @@ use tracing::{error, info, warn}; use zcash_client_backend::data_api::wallet::{ create_pczt_from_proposal, create_proposed_transactions, decrypt_and_store_transaction, - extract_and_store_transaction_from_pczt, input_selection::GreedyInputSelector, + extract_and_store_transaction_from_pczt, + input_selection::{GreedyInputSelector, SpendPolicy}, propose_transfer, ConfirmationsPolicy, SpendingKeys, }; use zcash_client_backend::data_api::{ - Account, AccountBirthday, AccountPurpose, AccountSource, InputSource, SentTransaction, - SentTransactionOutput, TransactionDataRequest, TransactionStatus, TransparentOutputFilter, - WalletRead, WalletUtxo, WalletWrite, + Account, AccountBirthday, AccountPurpose, AccountSource, CoinbaseFilter, InputSource, + SentTransaction, SentTransactionOutput, TransactionDataRequest, TransactionStatus, WalletRead, + WalletWrite, }; use zcash_client_backend::fees::{ standard::MultiOutputChangeStrategy, DustOutputPolicy, SplitPolicy, StandardFeeRule, @@ -37,7 +38,7 @@ use zcash_primitives::transaction::Transaction; use zcash_proofs::prover::LocalTxProver; use zcash_protocol::consensus::{BlockHeight, BranchId, Parameters}; use zcash_protocol::value::Zatoshis; -use zcash_protocol::{PoolType, ShieldedProtocol, TxId}; +use zcash_protocol::{PoolType, ShieldedPool, TxId}; use zcash_transparent::address::TransparentAddress; use zcash_transparent::builder::TransparentSigningSet; use zip32::DiversifierIndex; @@ -80,9 +81,17 @@ impl ProvingKeyCache { /// Build the Orchard proving + verifying keys. Expensive (full key generation); call once /// at startup, off the async runtime (e.g. under `spawn_blocking`). pub fn build() -> Self { + // The orchard crate splits `build()` into per-circuit-version keys. `FixedPostNu6_2` is the + // current (NU6.2-era) Orchard circuit. The cached-proving-key PCZT path only ever proves + // **V5 Orchard** sends: post-NU6.3 sends route to the fused `create_proposed_transactions` + // path (see `ironwood_forces_fused`), which builds its own `PostNu6_3` key via `LocalTxProver`. + // So this cache never proves a V6/Ironwood bundle and `FixedPostNu6_2` is always correct here, + // on every network (mainnet, testnet pre-4.134M, and post-NU6.3 chains where the cache goes + // unused). Reclaim `PostNu6_3` here only if the PCZT path is ever taught to build Ironwood. + let circuit = orchard::circuit::OrchardCircuitVersion::FixedPostNu6_2; ProvingKeyCache { - orchard_pk: orchard::circuit::ProvingKey::build(), - orchard_vk: orchard::circuit::VerifyingKey::build(), + orchard_pk: orchard::circuit::ProvingKey::build(circuit), + orchard_vk: orchard::circuit::VerifyingKey::build(circuit), } } } @@ -1919,7 +1928,7 @@ impl WalletActor { // *after* the actor's first connect/refresh, so calling `update_chain_tip` here with no // account would insert that low range, and a later call can't raise an existing range's // floor. So defer it: `maybe_bootstrap_account` calls `update_chain_tip` itself right - // after creating the account (birthday now set → the scan floors at the birthday). We + // after creating the account (birthday now set -> the scan floors at the birthday). We // still record the tip height/hash below so the bootstrap can run. let (tip, hash) = if self.account_id.is_some() { fetch_and_store_chain_tip(client, &mut self.db_data).await? @@ -2788,12 +2797,38 @@ impl WalletActor { /// Whether sends on this wallet *may* use the cached-Orchard PCZT path (so prove and store are /// separable). True for the default Orchard-only wallet with `cache_proving_key` on. A /// Sapling-spending wallet (or `cache_proving_key` off) uses the fused path, which has no - /// prove/store seam - see [`Self::do_send_fused`]. NB this gates on the *wallet's* pools, not - /// the send's recipients: even when this is true, an individual send that pays a Sapling output - /// is still diverted to the fused path (`request_pays_sapling_output`), because the cached - /// path's extractor is handed no Sapling verifying key. + /// prove/store seam - see [`Self::do_send_fused`]. Also forced off once NU6.3/Ironwood is active + /// (see [`Self::ironwood_forces_fused`]): post-NU6.3 sends ride the fused + /// `create_proposed_transactions` path, which is validated end-to-end for Ironwood. + /// + /// NB this gates on the *wallet's* pools (and consensus), not the send's recipients: even when + /// this is true, an individual send that pays a Sapling output is still diverted to the fused + /// path (`request_pays_sapling_output`), because the cached path's extractor is handed no + /// Sapling verifying key. fn cached_pczt_path(&self) -> bool { - self.orchard_keys.is_some() && !self.enabled_pools.contains(Pool::Sapling) + self.orchard_keys.is_some() + && !self.enabled_pools.contains(Pool::Sapling) + && !self.ironwood_forces_fused() + } + + /// Whether NU6.3/Ironwood is active as of the next block, forcing sends onto the fused build + /// path. Post-NU6.3 an Orchard-pool spend routes its payment/change into the Ironwood (V6) + /// bundle. Historically the gate was mandatory - the git-pinned `create_pczt_from_proposal` + /// hard-errored on an Ironwood bundle (`ProposalNotSupported`). The published + /// `zcash_client_backend 0.24.0-rc.1` now BUILDS Ironwood PCZTs (librustzcash#2543 landed), so + /// the gate is a conservative choice, not an upstream constraint: the fused + /// `create_proposed_transactions` path is the one validated end-to-end by the ironwood regtest + /// tier. TODO: drop this once the cached PCZT path (build -> `prove_sign_pczt` with the + /// `PostNu6_3` key + `create_ironwood_proof` (the ready other half) -> extract) is validated + /// against an NU6.3-active chain; Ironwood sends then reclaim the cached-proving-key speedup. + fn ironwood_forces_fused(&self) -> bool { + use zcash_protocol::consensus::{NetworkUpgrade, Parameters}; + self.tip_height + .map(|tip| { + self.network + .is_nu_active(NetworkUpgrade::Nu6_3, BlockHeight::from_u32(tip) + 1) + }) + .unwrap_or(false) } /// Whether a send should be pipelined: `[spend] pipeline_proving` on *and* the cached PCZT @@ -2839,6 +2874,11 @@ impl WalletActor { &change_strategy, request, policy, + // Shielded-only input selection (transparent UTXOs are spent via the separate + // lower-level `do_send_transparent` path), matching `do_send_fused`. `SpendPolicy`'s + // default permits every shielded pool with no transparent spending - the historical + // fully-shielded behavior (replaces the removed `TransparentSpendPolicy::ShieldedOnly`). + &SpendPolicy::default(), None, ) .map_err(|e| enrich_insufficient_funds(db, policy, classify_err(e)))?; @@ -2853,6 +2893,12 @@ impl WalletActor { account_id, OvkPolicy::Sender, &proposal, + // `None` lets librustzcash derive the expiry from the proposal's target height, + // matching the fused build path (and the pre-#2412 behaviour). + None, + // The change strategy above uses padded (default) Orchard bundles, so the bundle + // type must be `DEFAULT` to match (see `with_unpadded_orchard_pool_bundles`). + orchard::builder::BundleType::DEFAULT, ) .map_err(|e| enrich_insufficient_funds(db, policy, classify_pczt_err(e)))?; Ok((pczt, shape, start.elapsed())) @@ -2907,7 +2953,7 @@ impl WalletActor { )) } - /// Build, prove, and broadcast a send inline (today's behaviour): the whole of phase A→C runs + /// Build, prove, and broadcast a send inline (today's behaviour): the whole of phase A->C runs /// on the actor under `block_in_place`, so the actor (and thus sync) is blocked for the whole /// proof. `[spend] pipeline_proving` moves the proof off the actor - see /// [`Self::begin_or_queue_send`]. Used directly when pipelining is disabled or ineligible. @@ -2963,7 +3009,7 @@ impl WalletActor { return self.do_send_fused(usk, request, policy, privacy).await; } - // Cached-Orchard PCZT path: phase A (select+build) → phase B (prove+sign) → phase C + // Cached-Orchard PCZT path: phase A (select+build) -> phase B (prove+sign) -> phase C // (store), all on the actor. Each phase is timed so the send-latency log shows where the // cost lands on a large, note-fragmented wallet. let (pczt, shape, build) = self.build_proposal_and_pczt(request, policy, privacy)?; @@ -3035,6 +3081,12 @@ impl WalletActor { &change_strategy, request, policy, + // zecd's proposal path funds payments from shielded notes only (transparent + // UTXOs are spent via the separate lower-level `do_send_transparent` path), so + // the input selector must never pull in transparent UTXOs. `SpendPolicy::default` + // permits every shielded pool with no transparent spending, preserving the prior + // fully-shielded selection behavior (replaces `TransparentSpendPolicy::ShieldedOnly`). + &SpendPolicy::default(), None, ) .map_err(|e| enrich_insufficient_funds(db, policy, classify_err(e)))?; @@ -3053,7 +3105,6 @@ impl WalletActor { &SpendingKeys::from_unified_spending_key(usk), OvkPolicy::Sender, &proposal, - None, ) .map_err(|e| enrich_insufficient_funds(db, policy, classify_err(e)))?; if txids.len() > 1 { @@ -3349,14 +3400,14 @@ impl WalletActor { let receivers = db .get_transparent_receivers(account_id, true, true) .map_err(RpcError::database_internal)?; - let mut utxos: Vec = Vec::new(); + let mut utxos = Vec::new(); for addr in receivers.keys() { let outs = db .get_spendable_transparent_outputs( addr, target_height, policy, - TransparentOutputFilter::All, + CoinbaseFilter::AllTransparentOutputs, ) .map_err(RpcError::database_internal)?; utxos.extend(outs); @@ -3405,6 +3456,11 @@ impl WalletActor { BuildConfig::Standard { sapling_anchor: None, orchard_anchor: None, + // Upstream `BuildConfig::Standard` now carries `ironwood_anchor` + // unconditionally; this is a transparent-only send (no shielded spends), + // so there's no anchor. + ironwood_anchor: None, + orchard_pool_bundle_type: orchard::builder::BundleType::DEFAULT, }, ); @@ -4084,6 +4140,25 @@ fn prove_sign_pczt( } else { prover }; + // Ironwood (V6) proof step. A V6 transaction past NU6.3 carries a separate Ironwood bundle + // needing its own proof, produced from the same `PostNu6_3` proving key as the Orchard proof + // (mirrors devtool's `pczt/prove.rs`). + // + // DEAD CODE TODAY (kept intentionally): `do_send` routes every post-NU6.3 send to the fused + // `create_proposed_transactions` path (see `ironwood_forces_fused` in `cached_pczt_path`) and + // never reaches this PCZT prover for such a tx. So `requires_ironwood_proof()` is always false + // here (the branch is dead but compiled). This step is the ready other half: upstream's + // published RC now builds Ironwood PCZTs (librustzcash#2543), so dropping the + // `ironwood_forces_fused` gate - once the cached path is validated on an NU6.3-active chain - + // routes Ironwood sends back through here, reusing the cached key instead of the fused path's + // per-send `ProvingKey::build()`. + let prover = if prover.requires_ironwood_proof() { + prover + .create_ironwood_proof(&keys.orchard_pk) + .map_err(|e| RpcError::wallet(format!("Ironwood proof generation failed: {e:?}")))? + } else { + prover + }; let prover = if prover.requires_sapling_proofs() { prover .create_sapling_proofs(sapling_prover, sapling_prover) @@ -4119,6 +4194,30 @@ fn prove_sign_pczt( } } } + // Spend authorization for the Ironwood (V6) bundle - a separate bundle from Orchard, so its + // spends need their own signing pass or `extract_and_store_transaction_from_pczt` fails with + // `Ironwood(Extract(MissingSpendAuthSig))`. Ironwood reuses Orchard's spend crypto, so the same + // Orchard `ask` signs it; the loop mirrors the Orchard one (skip the dummy-spend + // `WrongSpendAuthorizingKey`, stop at `InvalidIndex`). Dead but compiled: the PCZT path never + // produces an Ironwood bundle (post-NU6.3 sends route to the fused path via + // `ironwood_forces_fused`), so the loop finds no ironwood spends and exits immediately. + { + let mut index = 0; + loop { + match signer.sign_ironwood(index, &ask) { + Ok(()) + | Err(SignerError::IronwoodSign( + orchard::pczt::SignerError::WrongSpendAuthorizingKey, + )) => index += 1, + Err(SignerError::InvalidIndex) => break, + Err(e) => { + return Err(RpcError::wallet(format!( + "Ironwood spend signing failed: {e:?}" + ))) + } + } + } + } Ok(signer.finish()) } @@ -4254,26 +4353,40 @@ fn enforce_full_privacy( /// since each Orchard action carries one spend and one output (a dummy filling whichever side is /// short). Mirrors the count Zallet's `orchard_actions` limit checks. `orchard_outputs` counts /// both payment outputs landing in the Orchard pool and Orchard change notes. +/// +/// Ironwood (V3) actions are Orchard-crypto actions in the separate Ironwood bundle - they carry +/// the same per-action proving cost, so they count toward this limit exactly like Orchard V2 +/// actions. Past NU6.3 the proposal represents these as the `Ironwood` pool (spends: a V3 +/// `orchard::Note`; outputs/change: `PoolType` Ironwood), so the counts fold both pools together. +/// This is what the pre-`3e0b8039e` `Note::protocol()` (which reported every orchard-crypto note as +/// `Orchard`) did implicitly; here we spell it out since `Note::pool()` now splits V3 out as +/// `Ironwood`. In the default (pre-NU6.3, non-ironwood) build no Ironwood actions exist, so the +/// Ironwood arm contributes nothing and the count is unchanged. fn step_orchard_actions( step: &zcash_client_backend::proposal::Step, ) -> (usize, usize) { + let is_orchard_family = + |pool: ShieldedPool| matches!(pool, ShieldedPool::Orchard | ShieldedPool::Ironwood); + let is_orchard_family_pooltype = + |pool: PoolType| matches!(pool, PoolType::Shielded(sp) if is_orchard_family(sp)); + let orchard_spends = step .shielded_inputs() .iter() .flat_map(|inputs| inputs.notes().iter()) - .filter(|note| note.note().protocol() == ShieldedProtocol::Orchard) + .filter(|note| is_orchard_family(note.note().pool())) .count(); let orchard_outputs = step .payment_pools() .values() - .filter(|&&pool| pool == PoolType::ORCHARD) + .filter(|&&pool| is_orchard_family_pooltype(pool)) .count() + step .balance() .proposed_change() .iter() - .filter(|change| change.output_pool() == PoolType::ORCHARD) + .filter(|change| is_orchard_family_pooltype(change.output_pool())) .count(); (orchard_spends, orchard_outputs) @@ -4481,7 +4594,7 @@ mod tests { #[test] fn transparent_selection_exact_cover_emits_no_change() { - // Inputs cover recipient + the no-change fee exactly → no change output. + // Inputs cover recipient + the no-change fee exactly -> no change output. let total = 50_000_000 + MARGINAL * 2; let (n, change, fee, has_change) = select_transparent_inputs( &[total], @@ -4515,7 +4628,7 @@ mod tests { .unwrap(); assert_eq!(n, 2); assert!(has_change); - // 2-in/2-out → max(2,2)=2 actions. + // 2-in/2-out -> max(2,2)=2 actions. assert_eq!(fee, MARGINAL * 2); assert_eq!(change, 120_000_000 - 100_000_000 - fee); assert_eq!(120_000_000, 100_000_000 + change + fee); @@ -4523,7 +4636,7 @@ mod tests { #[test] fn transparent_selection_fee_scales_with_input_count() { - // Three inputs, one recipient + change → 3-in/2-out → max(3,2)=3 actions. + // Three inputs, one recipient + change -> 3-in/2-out -> max(3,2)=3 actions. let values = [40_000_000, 40_000_000, 40_000_000]; let (n, change, fee, has_change) = select_transparent_inputs( &values, @@ -4543,7 +4656,7 @@ mod tests { #[test] fn transparent_selection_fee_scales_with_output_count() { - // One large input, two recipients + change → 1-in/3-out → max(grace, max(1,3)) = 3 actions. + // One large input, two recipients + change -> 1-in/3-out -> max(grace, max(1,3)) = 3 actions. let (n, change, fee, has_change) = select_transparent_inputs( &[100_000_000], 40_000_000, // two recipients summing to 0.4 ZEC... @@ -4562,7 +4675,7 @@ mod tests { #[test] fn transparent_selection_prices_p2sh_outputs_smaller() { - // 17 P2SH recipient outputs (32 bytes each) total 544 bytes → ceil(544/34) = 16 output + // 17 P2SH recipient outputs (32 bytes each) total 544 bytes -> ceil(544/34) = 16 output // actions, one fewer than the 17 a naive per-output count would charge. This is exactly how // the builder's ZIP-317 fee rule sizes them; a count-based formula would mis-fee here. const P2SH_OUT: usize = 8 + 1 + 23; @@ -4577,7 +4690,7 @@ mod tests { GRACE, ) .unwrap(); - // With change: total out = 544 + 34 = 578 → ceil(578/34) = 17 actions; 1 input → fee = 17m. + // With change: total out = 544 + 34 = 578 -> ceil(578/34) = 17 actions; 1 input -> fee = 17m. assert_eq!(fee, MARGINAL * 17); } @@ -4595,7 +4708,7 @@ mod tests { Address::Transparent(TransparentAddress::PublicKeyHash([b; 20])).to_zcash_address(&net) }; - // Two bare transparent recipients → Some, with amounts preserved in order. + // Two bare transparent recipients -> Some, with amounts preserved in order. let req = TransactionRequest::new(vec![ Payment::without_memo(taddr(1), Zatoshis::const_from_u64(50_000_000)), Payment::without_memo(taddr(2), Zatoshis::const_from_u64(10_000_000)), @@ -4863,11 +4976,11 @@ mod tests { assert_eq!(orchard_action_overflow(50, 50, 50), None); assert_eq!(orchard_action_overflow(10, 50, 50), None); assert_eq!(orchard_action_overflow(0, 0, 50), None); - // Only outputs overflow → blame outputs. + // Only outputs overflow -> blame outputs. assert_eq!(orchard_action_overflow(3, 51, 50), Some((51, "outputs"))); - // Only inputs overflow → blame inputs. + // Only inputs overflow -> blame inputs. assert_eq!(orchard_action_overflow(80, 2, 50), Some((80, "inputs"))); - // Both overflow → blame actions (the max). + // Both overflow -> blame actions (the max). assert_eq!(orchard_action_overflow(60, 70, 50), Some((70, "actions"))); // A tight cap of 1: a single extra output trips it. assert_eq!(orchard_action_overflow(1, 2, 1), Some((2, "outputs"))); diff --git a/src/wallet/read.rs b/src/wallet/read.rs index 8e3206a..2b169ba 100644 --- a/src/wallet/read.rs +++ b/src/wallet/read.rs @@ -13,7 +13,7 @@ use zcash_keys::address::{Address, UnifiedAddress}; use zcash_keys::encoding::AddressCodec as _; use zcash_keys::keys::UnifiedFullViewingKey; use zcash_primitives::transaction::builder::DEFAULT_TX_EXPIRY_DELTA; -use zcash_protocol::{ShieldedProtocol, TxId}; +use zcash_protocol::{ShieldedPool, TxId}; use zip32::{DiversifierIndex, Scope}; use crate::network::ZNetwork; @@ -24,6 +24,11 @@ use crate::wallet::open::{data_db_path, open_read}; pub struct BalanceInfo { pub orchard_spendable: u64, pub sapling_spendable: u64, + /// Ironwood (NU6.3, Orchard V3) spendable value. Read from `AccountBalance::ironwood_balance()`, + /// which the pinned scan-model librustzcash rev surfaces (the same API devtool reads). 0 until + /// NU6.3 activates and the wallet holds ironwood notes - so 0 on mainnet, and on testnet until + /// NU6.3 activates at height 4_134_000. + pub ironwood_spendable: u64, /// Spendable transparent (unshielded) value. Spendable here means "usable as an input": zecd /// spends transparent UTXOs by auto-shielding them into a shielded send. pub transparent_spendable: u64, @@ -77,9 +82,25 @@ pub fn balance( .unshielded_balance() .change_pending_confirmation() .into_u64(); + // Ironwood (Orchard V3) balance. The pinned librustzcash ironwood line (the + // `dw/ironwood-scan-model` rev zcash-devtool builds against) rolls received ironwood + // notes into `AccountBalance::ironwood_balance()`, exactly as devtool's `balance.rs` + // reads it; sum it into the spendable/pending/immature buckets like the other pools. + // (0 pre-NU6.3, so a no-op on mainnet / pre-activation testnet.) + info.ironwood_spendable += bal.ironwood_balance().spendable_value().into_u64(); + info.pending += bal + .ironwood_balance() + .value_pending_spendability() + .into_u64(); + info.immature += bal + .ironwood_balance() + .change_pending_confirmation() + .into_u64(); } - info.total_spendable = - info.orchard_spendable + info.sapling_spendable + info.transparent_spendable; + info.total_spendable = info.orchard_spendable + + info.sapling_spendable + + info.transparent_spendable + + info.ironwood_spendable; } Ok(info) } @@ -182,6 +203,14 @@ pub struct UnspentNote { /// The diversified address the note was received on, when the wallet recorded one /// (change/internal notes have none). pub address: Option, + /// The shielded pool the note is in, as a `v_tx_outputs.output_pool` code: 2 = Sapling, + /// 3 = Orchard, 4 = ironwood (NU6.3). Sourced from `v_tx_outputs.output_pool` (the + /// `ironwood_pool_code_views` migration tags ironwood outputs 4). Ironwood is a first-class pool + /// in the pinned librustzcash line: a received ironwood note lives in `ironwood_received_notes` + /// and comes back from `select_unspent_notes` in `ReceivedNotes::ironwood()` (a separate + /// accessor from `orchard()`), so `list_unspent` must request `ShieldedPool::Ironwood` and read + /// that accessor to surface it. Surfaced as `listunspent`'s `pool`. + pub pool: i64, } fn open_conn(wallet_dir: &Path) -> anyhow::Result { @@ -636,7 +665,7 @@ pub fn first_scanned_block(wallet_dir: &Path) -> anyhow::Result 32 /// bytes). `listsinceblock` uses this to tell a reorged-away cursor (well-formed, whose /// `blocks` row `perform_rewind` deleted - worth surviving) from a malformed client argument /// (still `-5`). @@ -688,9 +717,13 @@ pub fn list_unspent(network: ZNetwork, wallet_dir: &Path) -> anyhow::Result, bool)> = HashMap::new(); // Map (txid, output index) -> receiving address for the shielded outputs the wallet recorded - // one for (change/internal notes have none). Spans every shielded pool (2 = Sapling, - // 3 = Orchard). + // one for (change/internal notes have none), plus -> shielded pool code for every shielded + // output (2 = Sapling, 3 = Orchard, 4 = ironwood). The pool map is keyed off + // `v_tx_outputs.output_pool` (the valar fork's `ironwood_pool_code_views` migration tags + // ironwood 4) rather than the note's protocol, so an ironwood (Orchard V3) note - which + // librustzcash returns in `ReceivedNotes::ironwood()` (its own accessor) - is labelled ironwood. let mut out_addr: HashMap<(String, u32), String> = HashMap::new(); + let mut out_pool: HashMap<(String, u32), i64> = HashMap::new(); { let conn = open_conn(wallet_dir)?; let mut stmt = @@ -707,36 +740,54 @@ pub fn list_unspent(network: ZNetwork, wallet_dir: &Path) -> anyhow::Result>(0)?, r.get::<_, u32>(1)?, - r.get::<_, String>(2)?, + r.get::<_, i64>(2)?, + r.get::<_, Option>(3)?, )) })?; for row in rows { - let (txid, idx, addr) = row?; - out_addr.insert((txid_display(&txid), idx), addr); + let (txid, idx, pool, addr) = row?; + let txid = txid_display(&txid); + out_pool.insert((txid.clone(), idx), pool); + if let Some(addr) = addr { + out_addr.insert((txid, idx), addr); + } } } let mut out = Vec::new(); // All shielded pools zecd supports; a note only exists if the wallet received it, so querying // every pool is safe regardless of which pools are enabled in config. - let protocols: Vec = crate::pools::Pool::SUPPORTED + #[allow(unused_mut)] + let mut protocols: Vec = crate::pools::Pool::SUPPORTED .iter() .filter_map(|p| p.shielded_protocol()) .collect(); + // Ironwood (NU6.3, Orchard V3) is a first-class pool in the pinned librustzcash line: its notes + // live in the separate `ironwood_received_notes` table and are returned by + // `SpendableNotes::ironwood()`, *not* folded into `orchard()`. `select_unspent_notes` only + // queries a pool when it appears in `sources`, so an ironwood receive is invisible to + // `listunspent` unless we ask for it here (and read `notes.ironwood()` below). It is not a + // `Pool::SUPPORTED` member (ironwood has no UA receiver - see `pools.rs`), so add it explicitly. + // Harmless pre-NU6.3: the ironwood note table is simply empty on mainnet / pre-activation testnet. + protocols.push(ShieldedPool::Ironwood); for account in db.get_account_ids()? { let notes = db.select_unspent_notes(account, &protocols, target_height, &[])?; // Both `notes.sapling()` and `notes.orchard()` yield `ReceivedNote`s with the same // `txid`/`output_index`/`note_value` surface; collect each into the shared output list. - let mut push = |txid: String, vout: u32, value: u64| { + // `default_pool` is the note's protocol pool (2 Sapling / 3 Orchard); the per-output + // `out_pool` map overrides it so an ironwood (Orchard V3) note is labelled 4. + let mut push = |txid: String, vout: u32, value: u64, default_pool: i64| { let (mined_height, trusted) = tx_meta.get(&txid).copied().unwrap_or((None, false)); - let address = out_addr.get(&(txid.clone(), vout)).cloned(); + let key = (txid.clone(), vout); + let address = out_addr.get(&key).cloned(); + let pool = out_pool.get(&key).copied().unwrap_or(default_pool); out.push(UnspentNote { vout, txid, @@ -744,6 +795,7 @@ pub fn list_unspent(network: ZNetwork, wallet_dir: &Path) -> anyhow::Result anyhow::Result`) but returned in + // their own `ironwood()` accessor; label them pool code 4 (the `out_pool` map overrides this + // with the recorded `v_tx_outputs.output_pool` when present, which is also 4). + for note in notes.ironwood() { + let value = note + .note_value() + .map_err(|e| anyhow!("note value: {e:?}"))? + .into_u64(); + push( + note.txid().to_string(), + note.output_index() as u32, + value, + 4, + ); } } @@ -777,20 +854,33 @@ pub fn list_unspent(network: ZNetwork, wallet_dir: &Path) -> anyhow::Result = vec![ ( "sapling_received_notes", "sapling_received_note_spends", "sapling_received_note_id", "output_index", + 2, ), ( "orchard_received_notes", "orchard_received_note_spends", "orchard_received_note_id", "action_index", + 3, ), ]; + // An unmined ironwood note is stored in its own `ironwood_received_notes` table (not + // `orchard_received_notes`), so a 0-conf ironwood receive from the mempool stream is only + // visible if we query that table too. Same query shape; pool code 4. Empty pre-NU6.3. + pools.push(( + "ironwood_received_notes", + "ironwood_received_note_spends", + "ironwood_received_note_id", + "action_index", + 4, + )); // Both the note's own creating tx and any spending tx are gated by the shared // `tx_unexpired_sql` predicate, so this supplement and the rebroadcast set agree with the // selector/balances on exactly what "unexpired" means (incl. the unknown-expiry staleness @@ -799,7 +889,7 @@ pub fn list_unspent(network: ZNetwork, wallet_dir: &Path) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result