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..77f044d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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-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 +174,8 @@ 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-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..36a0b1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -822,8 +822,7 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "equihash" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "306286e8dcc39ab3dfceb74c792ce8baffdab90591321d3ffaae64829734c37f" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "blake2b_simd", "corez", @@ -848,8 +847,7 @@ dependencies = [ [[package]] name = "f4jumble" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d42773cb15447644d170be20231a3268600e0c4cea8987d013b93ac973d3cf7" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "blake2b_simd", ] @@ -954,6 +952,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" @@ -1145,8 +1155,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1522,8 +1534,7 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "incrementalmerkletree" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30821f91f0fa8660edca547918dc59812893b497d07c1144f326f07fdd94aba9" +source = "git+https://github.com/zcash/incrementalmerkletree?rev=7b23adb67044d2a9f4a68759f1e6340c3fc4735a#7b23adb67044d2a9f4a68759f1e6340c3fc4735a" dependencies = [ "either", ] @@ -1788,6 +1799,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" @@ -1878,9 +1898,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "orchard" -version = "0.14.0" +version = "0.15.0-pre.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a54f8d29bfb1e76a9d4e868a1a08cce2e57dd2bdc66232982822ad3114b91ab3" +checksum = "d77752de9c2865ca4a0d962b201b473d60bbe438d53a964a8707ec4bd401d631" dependencies = [ "aes", "bitvec", @@ -1984,8 +2004,7 @@ dependencies = [ [[package]] name = "pczt" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4712bd8688e1d1aa0eb0e78dc5c846bc2d70dcd1de544ecf053516df174c4fcd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "blake2b_simd", "bls12_381", @@ -2770,8 +2789,7 @@ dependencies = [ [[package]] name = "shardtree" version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "359e552886ae54d1642091645980d83f7db465fd9b5b0248e3680713c1773388" +source = "git+https://github.com/zcash/incrementalmerkletree?rev=7b23adb67044d2a9f4a68759f1e6340c3fc4735a#7b23adb67044d2a9f4a68759f1e6340c3fc4735a" dependencies = [ "bitflags", "either", @@ -3751,9 +3769,8 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" [[package]] name = "zcash_address" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58342d0aaa8e2fa98849636f52800ac4bf020574c944c974742fc933db58cac2" +version = "0.13.0-pre.0" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "bech32 0.11.1", "bs58", @@ -3766,8 +3783,7 @@ dependencies = [ [[package]] name = "zcash_client_backend" version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04583f0945d5ff80eee80c1844fb0af8b7cc46a0ac26d5e12c4ccd474bec0f03" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "base64 0.22.1", "bech32 0.11.1", @@ -3775,8 +3791,8 @@ dependencies = [ "bls12_381", "bs58", "byteorder", - "crossbeam-channel", "document-features", + "flume", "getset", "group", "hex", @@ -3816,9 +3832,8 @@ dependencies = [ [[package]] name = "zcash_client_sqlite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b99abb0e534b72705f49cb43d5f1a448844b49b9c48590f87888cb84ce6d29" +version = "0.21.1" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "bip32", "bitflags", @@ -3864,8 +3879,7 @@ dependencies = [ [[package]] name = "zcash_encoding" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1440921903cdb86133fb9e2fe800be488015db2939a30bedb413078a1acb0306" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "corez", "hex", @@ -3874,9 +3888,8 @@ dependencies = [ [[package]] name = "zcash_keys" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbcdfbb5c8edb247439d72a397abaae9b7dd14a1c070e7e4fc3536924f9065f" +version = "0.15.0-pre.0" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "bech32 0.11.1", "bip32", @@ -3917,9 +3930,8 @@ dependencies = [ [[package]] name = "zcash_primitives" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c69e07f5eb3f682a6467b4b08ee4956f1acd1e886d70b21c4766953b3a1beba2" +version = "0.29.0-pre.0" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "blake2b_simd", "block-buffer 0.11.0-rc.3", @@ -3948,9 +3960,8 @@ dependencies = [ [[package]] name = "zcash_proofs" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3de6b0ca82e08a9d38b1121f87c5b180b5feac19fecba074cb582882210d2371" +version = "0.29.0-pre.0" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "bellman", "blake2b_simd", @@ -3971,9 +3982,8 @@ dependencies = [ [[package]] name = "zcash_protocol" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bec496a0bd62dae98c4b26f51c5dab112d0c5350bbc2ccfdfd05bb3454f714d" +version = "0.10.0-pre.0" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "corez", "document-features", @@ -4010,9 +4020,8 @@ dependencies = [ [[package]] name = "zcash_transparent" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15df1908b428d4edeb7c7caae5692e05e2e92e5c38007a40b20ac098efdffd96" +version = "0.9.0-pre.0" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "bip32", "bs58", @@ -4035,7 +4044,7 @@ dependencies = [ [[package]] name = "zecd" -version = "0.4.3" +version = "0.5.0-rc2" dependencies = [ "age", "anyhow", @@ -4161,8 +4170,7 @@ dependencies = [ [[package]] name = "zip321" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fef061351aa99792deb8f62b00426863a317ca2cb124c9de85550e3ef0f0f2f" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "base64 0.22.1", "nom", diff --git a/Cargo.toml b/Cargo.toml index 383154e..d8cad0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zecd" -version = "0.4.3" +version = "0.5.0-rc2" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" @@ -70,11 +70,11 @@ 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.0-pre.2", default-features = false, features = ["circuit"] } +zcash_address = "0.13.0-pre.0" # `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. +# zcashd's `rpc/misc.cpp` and zallet. Pinned to the librustzcash rev in `[patch]` (0.4.0 there). zcash_encoding = "0.4" # `transparent-inputs` lets transparent receivers surface in getrawtransaction/getaddressinfo # and the received-by-address read paths; zecd's own wallets never expose transparent receivers. @@ -82,11 +82,11 @@ zcash_encoding = "0.4" # 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_keys = { version = "0.15.0-pre.0", features = ["unstable", "orchard"] } +zcash_primitives = "0.29.0-pre.0" +zcash_proofs = { version = "0.29.0-pre.0", features = ["bundled-prover"] } +zcash_protocol = { version = "0.10.0-pre.0", features = ["local-consensus"] } +zcash_transparent = "0.9.0-pre.0" # 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" @@ -119,16 +119,112 @@ 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 (this +# RC branch pins the ironwood librustzcash line, which 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. This +# is an RC posture - the real release will pin published crates.io librustzcash instead of the git rev. [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+. +# Patched dependencies. +# +# 1. 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+. +# +# 2. librustzcash → the OFFICIAL zcash/librustzcash repo, pinned by **git rev** to the ironwood / +# NU6.3 line (`dw/ironwood-scan-model` HEAD, rev `1105d8203`; zcash/librustzcash#2539) - the same +# ironwood line the confirmed-working reference wallet **zcash-devtool** builds against. This is a +# **plain upstream pin: zecd no longer carries a librustzcash fork.** (Previously it pinned +# `zecrocks/librustzcash` = this rev + one cherry-pick, "Backfill received-note memos for own +# self-sends". That cherry-pick was DROPPED here to move off the fork - see below - and is being +# submitted upstream separately.) The ironwood leaf crates are published to crates.io as release +# candidates (`zcash_primitives 0.29.0-pre.0`, `orchard 0.15.0-pre.2`, `zcash_protocol 0.10.0-pre.0`, +# `zcash_keys 0.15.0-pre.0`, `zcash_address 0.13.0-pre.0`, `zcash_transparent 0.9.0-pre.0`, +# `zcash_proofs 0.29.0-pre.0`), so `orchard` comes straight from crates.io (no git patch); but the +# wallet crates (`zcash_client_backend`/`zcash_client_sqlite`/`pczt`/`zip321`/`zcash_encoding`) are +# NOT yet published with the ironwood changes, so the whole librustzcash set stays git-pinned to the +# rev above. Repin straight to crates.io once the ironwood wallet crates are published. +# +# **Dropped cherry-pick - self-send memo backfill (fe9c4144).** It added a `zcash_client_sqlite` +# `put_blocks` pass that re-decrypts the wallet's own mined txs to fill a received note's NULL memo: +# a shielded self-payment is a `Recipient::External` (no received note at send time), the block +# scanner later creates the note with a NULL memo (compact outputs carry none), and enhancement +# skips the wallet's own txs (raw pre-stored → Status-only). The regtest suite showed this gap is +# **narrow and self-healing**: the live mempool path fills the memo on the authoring node in the +# common case, and a from-seed restore (no pre-stored raw → enhancement runs) recovers it - so it +# only bites the *authoring* node when the mempool missed its own self-send, and a rescan fixes it. +# Guarded by an ironwood-tier regtest that forces the block-scan-only path (send → stop → mine while +# down → restart same datadir); that test FAILS on this fork-free pin and PASSES with the cherry-pick, +# which is why it lives on a branch that carries the fix. Once the fix merges upstream, bump the rev. +# Not carried either: the create_pczt Ironwood-build (librustzcash#2543) - upstream +# `create_pczt_from_proposal` at this rev still rejects Ironwood PCZTs (`step_uses_ironwood`), so +# zecd routes **Ironwood sends through the fused `create_proposed_transactions` path** via the +# `ironwood_forces_fused` gate in `actor.rs` (~4.5 s/send keygen; drop the gate to reclaim the PCZT +# cache once upstream removes its guard). +# The upstream line carries the rest of the ironwood API zecd uses: ironwood receive **memos** +# (`decrypt_transaction` decrypts the Ironwood bundle), `ironwood_balance`, `Note::pool()`, the +# ironwood `ChainState`, and (for the fused send path) native Ironwood build+prove. NB the line +# pins **orchard `0.15.0-pre.2`** and needs the explicit-checkpoint-retention **shardtree** APIs plus +# a `clear_flags` checkpoint-pruning panic fix, so shardtree + incrementalmerkletree are git-patched +# below to **upstream `zcash/incrementalmerkletree`** (main, rev `7b23adb6`) - also not a fork. +# +# This rev **includes `00c789f9`** ("Select shielded inputs from a single pool group"): shielded +# inputs are selected 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). It is applied *unconditionally* +# (pre-NU6.3 too), so zecd's 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 (fund Orchard +# 2 ZEC / Sapling 1 ZEC; a 1.5-ZEC payment to the Sapling receiver draws the Orchard group and +# produces an Orchard->Sapling turnstile). We accept this to stay lock-step with devtool/upstream. +# A `[patch]` git-workspace must be all-or-nothing (a git crate's path-siblings would otherwise +# conflict with the same crates.io versions), so the whole librustzcash set is pinned to this one +# rev. `[patch]` is global (cargo can't feature-gate it), but upstream exposes the ironwood types +# unconditionally (no `cfg(zcash_unstable = "nu6.3")` gating remains upstream), and NU6.3 stays +# inactive without the `ironwood` cargo feature, so the default build is released-equivalent. +# Pinned by **git rev** (not a sibling path) so CI - which has no sibling checkout - can fetch it. +# +# Ironwood model at this rev (matches devtool, differs from the old valar fork): 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`). Bump this rev (and the crates.io `-pre` version +# reqs above) as the upstream branch advances. NOT threaded into the Docker/release pipeline +# (intentionally - would ship unreleased WIP into reproducible images). [patch.crates-io] i18n-embed-fl = { path = "vendor/i18n-embed-fl" } +# equihash + f4jumble are leaf crates in the librustzcash workspace that the patched crates depend +# on; patch them to the same rev so a git-workspace sibling can't clash with a crates.io copy (matches +# zcash-devtool's ironwood-valar patch set). +equihash = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +f4jumble = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +zcash_address = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +zcash_encoding = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +zcash_protocol = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +zip321 = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +pczt = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +zcash_keys = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +zcash_client_backend = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +zcash_client_sqlite = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +zcash_primitives = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +zcash_proofs = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +zcash_transparent = { git = "https://github.com/zcash/librustzcash", rev = "1105d8203eb595c28c39929fba65569de9696851" } +# The ironwood librustzcash line requires the explicit-checkpoint-retention shardtree APIs +# (`ensure_retained`/`retained_checkpoints`/`remove_retained_checkpoint`) plus the `clear_flags` +# checkpoint-pruning panic fix. Both are in upstream `zcash/incrementalmerkletree` **main** but not +# in a published shardtree release compatible with this line (the librustzcash rev requires +# `shardtree ^0.6.2`; published 0.6.2 lacks the APIs and 0.7.0 breaks that req), so pin both crates +# to upstream main by git rev - **upstream, not a fork.** Mirror this exact rev on both crates. Drop +# once a shardtree release carrying these APIs lands within the `^0.6` line (or the librustzcash line +# moves to `shardtree 0.7`). +shardtree = { git = "https://github.com/zcash/incrementalmerkletree", rev = "7b23adb67044d2a9f4a68759f1e6340c3fc4735a" } +incrementalmerkletree = { git = "https://github.com/zcash/incrementalmerkletree", rev = "7b23adb67044d2a9f4a68759f1e6340c3fc4735a" } 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..4538a4f 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,36 @@ 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 + /// prove/store seam - see [`Self::do_send_fused`]. Also forced off once NU6.3/Ironwood is active + /// (see [`Self::ironwood_forces_fused`]): upstream `create_pczt_from_proposal` still rejects a + /// built PCZT carrying an Ironwood bundle (`step_uses_ironwood`/`pczt_parts.ironwood.is_some()` -> + /// `ProposalNotSupported`), so post-NU6.3 sends ride the fused `create_proposed_transactions` + /// path, which upstream fully supports for Ironwood. 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. 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, which the cached PCZT prove path cannot build - upstream `create_pczt_from_proposal` + /// hard-errors on an Ironwood bundle (`ProposalNotSupported`). Upstream's fused + /// `create_proposed_transactions` builds and proves Ironwood natively, so Ironwood sends take it. + /// TODO(upstream): drop this once `create_pczt_from_proposal` accepts Ironwood bundles - then + /// Ironwood sends ride the cached PCZT path and reuse the `PostNu6_3` key (the paired + /// `create_ironwood_proof` step in `prove_sign_pczt` is the ready other half). + 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 +2872,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 +2891,9 @@ 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, ) .map_err(|e| enrich_insufficient_funds(db, policy, classify_pczt_err(e)))?; Ok((pczt, shape, start.elapsed())) @@ -2907,7 +2948,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 +3004,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 +3076,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)))?; @@ -3349,14 +3396,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 +3452,10 @@ 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, }, ); @@ -4084,6 +4135,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`). Only compiled under the `ironwood` feature, whose + // `ProvingKeyCache` builds the `PostNu6_3` key. + // + // 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, because upstream `create_pczt_from_proposal` + // rejects Ironwood PCZTs. So `requires_ironwood_proof()` is always false here (the branch is + // dead but compiled). This step is the ready other half: once upstream lets the PCZT path build + // Ironwood bundles, dropping the `ironwood_forces_fused` gate 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 +4189,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 +4348,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 +4589,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 +4623,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 +4631,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 +4651,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 +4670,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 +4685,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 +4703,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 +4971,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