From ec60ff4948d7960fbec6361080c24ca89da27dc3 Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Tue, 7 Jul 2026 11:22:49 -0500 Subject: [PATCH 01/13] Ironwood (NU6.3) support pinned to a working librustzcash branch Add opt-in Ironwood (NU6.3 / V6) support behind a default-off `ironwood` cargo feature: the PostNu6_3 proving key and proof step, the NU6.3 regtest activation height, ironwood receive discovery and balance rollup, and `pool == "ironwood"` labelling. The librustzcash set is pinned to a currently-working zecrocks fork of librustzcash, which will be repointed to mainline librustzcash before release. With the feature off the default build is unchanged. Also adds transparent recovery-window policy controls and an ironwood regtest. Co-authored-by: AI --- .github/workflows/ci.yml | 22 + .github/workflows/regtest.yml | 125 ++++- Cargo.lock | 88 ++-- Cargo.toml | 122 ++++- deny.toml | 15 +- regtest-harness/src/lib.rs | 181 ++++++- regtest-harness/tests/regtest_ironwood.rs | 608 ++++++++++++++++++++++ regtest-harness/tests/regtest_sapling.rs | 81 +-- scripts/conformance.py | 6 +- src/chain/mod.rs | 8 +- src/chain/zebra.rs | 112 ++-- src/config.rs | 24 +- src/network.rs | 11 + src/pools.rs | 42 +- src/rpc/wallet_methods.rs | 50 +- src/sync/engine.rs | 42 +- src/wallet/actor.rs | 141 +++-- src/wallet/read.rs | 136 ++++- 18 files changed, 1584 insertions(+), 230 deletions(-) create mode 100644 regtest-harness/tests/regtest_ironwood.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a85d47..8ac457c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,3 +105,25 @@ jobs: - name: Check at MSRV run: cargo check --all-targets + + # Guards the DEV-ONLY ironwood / NU6.3 build. The default tier above builds the pinned official + # zcash/librustzcash RC in released-equivalent mode (NU6.3 inactive); this job clippy-checks the + # `ironwood` feature so the ironwood scaffolding (scan/balance/send paths) can't silently bit-rot. + # Upstream exposes the ironwood APIs unconditionally, so the feature alone gates zecd's paths - no + # `zcash_unstable="nu6.3"` cfg is needed any more (the old valar-fork approach is retired). + ironwood: + runs-on: ubuntu-latest + 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 + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Clippy (--features ironwood) + run: cargo clippy --features ironwood --all-targets -- -D warnings diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index 49a5d2d..c06e09c 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-rc.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,104 @@ 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 RC (`zfnd/zebra:6.0.0-rc.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 this ironwood-RC-update branch so iterating here auto-runs the + # NU6.3 e2e without a manual dispatch. `head_ref` matches it on the PR trigger (feature branches + # run via pull_request, where `github.ref` is the PR merge ref, not the branch); `ref` matches a + # direct push. Never on other branches' PRs - the emersonian/* lwd/devtool images are dev + # artifacts and must not gate anyone else's CI. Remove this branch keying when ironwood merges. + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.head_ref == 'ironwood' || github.ref == 'refs/heads/ironwood' }} + runs-on: ubuntu-latest + name: regtest (ironwood / NU6.3) + env: + # zebrad is now the OFFICIAL ironwood release candidate on Docker Hub (`zfnd/zebra:6.0.0-rc.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-rc.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-rc.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 must be built with the ironwood feature to scan ironwood notes and report the ironwood + # balance/pool. Official upstream (dw/ironwood-scan-model) exposes the ironwood wallet APIs + # unconditionally, so no `zcash_unstable="nu6.3"` cfg is needed any more - the feature alone + # gates zecd's ironwood paths. (The harness itself needs neither - it only spawns this binary.) + - name: Build zecd (release, --features ironwood) + run: cargo build --release --features ironwood --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" + 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/Cargo.lock b/Cargo.lock index bed8735..8de90b7 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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=decefc469dbf1e686b20b7e7dcaa7cb4f122125b#decefc469dbf1e686b20b7e7dcaa7cb4f122125b" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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=decefc469dbf1e686b20b7e7dcaa7cb4f122125b#decefc469dbf1e686b20b7e7dcaa7cb4f122125b" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" 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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" dependencies = [ "bip32", "bs58", @@ -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/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" dependencies = [ "base64 0.22.1", "nom", diff --git a/Cargo.toml b/Cargo.toml index 383154e..272b8bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] +# Ironwood (NU6.3 / V6). DEV-ONLY: builds against the official zcash/librustzcash rev that +# zcash-devtool's `ironwood-valar` branch uses (see `[patch.crates-io]`). Gates zecd's own ironwood +# paths: the `PostNu6_3` proving key + the ironwood proof step in the send path, the NU6.3 regtest +# activation height, and `pool == "ironwood"` labelling. Build with just `cargo build --features +# ironwood` - upstream exposes the ironwood types unconditionally, so NO `--cfg zcash_unstable="nu6.3"` +# RUSTFLAG is needed (unlike the old valar fork). The default build (feature off) stays +# released-equivalent: NU6.3 is inactive and no ironwood paths run. Sends and receives both work on +# the pinned rev (matching zcash-devtool's ironwood-valar), via `AccountBalance::ironwood_balance()` +# for receives and the ironwood proof step for sends. +ironwood = [] [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 (replacing the old valargroup fork). The +# ironwood / NU6.3 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 zecd leans on (`zcash_client_backend`/`zcash_client_sqlite`) and their +# siblings (`pczt`/`zip321`/`zcash_encoding`) are NOT yet published with the ironwood changes. +# `main` itself still has the wallet-side ironwood work unfinished (`client_sqlite` has +# `ShieldedPool::Ironwood => todo!(...)`), so we track upstream's **`dw/ironwood-scan-model`** +# line (zcash/librustzcash#2539), the same ironwood line the confirmed-working reference wallet +# **zcash-devtool** (`ironwood-valar`) builds against. To avoid depending on a contributor's fork +# staying current, zecd pins **our own fork branch - `zecrocks/librustzcash` `ironwood-scan-model`** +# - which is the current upstream `dw/ironwood-scan-model` HEAD **plus two cherry-picks of commits it +# still lacks**: +# 1. Gustavo Valverde's "build Ironwood PCZTs in create_pczt_from_proposal" (from librustzcash#2543, +# authorship preserved) - removes the old `pczt_parts.ironwood.is_some()` bailout and threads +# Ironwood spend/output metadata through `Updater::update_ironwood_with`, so +# `create_pczt_from_proposal` builds V6 Ironwood PCZTs and Ironwood sends ride the **cached +# proving-key PCZT path** (see `cached_pczt_path`/`prove_sign_pczt` in `actor.rs`) instead of the +# fused fallback. +# 2. "Add the ironwood_tree_retained_checkpoints table" (from librustzcash#2554) - creates the +# missing SQLite table the ironwood shardtree writes when it retains a durable anchor checkpoint +# (every `ANCHOR_RETENTION_INTERVAL` = 288 blocks post-NU6.3). Only the Sapling/Orchard tables +# existed; without it, any scan crossing a 288-boundary fails with +# `no such table: ironwood_tree_retained_checkpoints`. Guarded by `regtest_ironwood.rs`'s +# anchor-retention step. +# To maintain the branch: rebase our cherry-picks onto the latest upstream `dw/ironwood-scan-model`, +# push, bump the rev below; **drop each cherry-pick and repin straight to upstream as its PR (#2543 / +# #2554, or equivalents) merges.** The +# rebased upstream line already carries the rest: ironwood receive **memos** work +# (`decrypt_transaction` decrypts the Ironwood bundle), and the tip compile break is fixed. The pin +# carries the full ironwood API zecd uses (`ironwood_balance` for receives; `requires_ironwood_proof`/ +# `create_ironwood_proof` + `sign_ironwood` + the `PostNu6_3` circuit for sends; the ironwood +# `ChainState`; `Note::pool()`). NB the branch bumped **orchard to `0.15.0-pre.2`** and requires the +# explicit-checkpoint-retention **shardtree** APIs, so shardtree + incrementalmerkletree are also +# git-patched below to the exact `zcash/incrementalmerkletree` rev the branch pins. +# +# 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/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +f4jumble = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +zcash_address = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +zcash_encoding = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +zcash_protocol = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +zip321 = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +pczt = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +zcash_keys = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +zcash_client_backend = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +zcash_client_sqlite = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +zcash_primitives = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +zcash_proofs = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +zcash_transparent = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +# The `dw/ironwood-scan-model` line (our fork branch above) requires the explicit-checkpoint-retention +# shardtree APIs (`ensure_retained`/`retained_checkpoints`/`remove_retained_checkpoint`) that aren't in +# a published shardtree yet, so it patches shardtree + incrementalmerkletree to `zcash/incrementalmerkletree` +# main by git rev. Mirror the exact rev the branch pins (its own `[patch]`), or `zcash_client_backend` +# fails to build against the crates.io shardtree. Drop when a shardtree release with these APIs is cut. +shardtree = { git = "https://github.com/zcash/incrementalmerkletree", rev = "decefc469dbf1e686b20b7e7dcaa7cb4f122125b" } +incrementalmerkletree = { git = "https://github.com/zcash/incrementalmerkletree", rev = "decefc469dbf1e686b20b7e7dcaa7cb4f122125b" } diff --git a/deny.toml b/deny.toml index a63b284..8c12200 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 our own fork branch, +# `zecrocks/librustzcash` `ironwood-scan-model` = upstream `dw/ironwood-scan-model` HEAD + a +# cherry-pick of the create_pczt Ironwood-build commit from librustzcash#2543. We pin our own fork +# (rather than a contributor's) so the branch can't drift out from under us. Swap this back to +# `https://github.com/zcash/librustzcash` when #2543 (or an equivalent create_pczt Ironwood build) +# merges into `dw/ironwood-scan-model` and we repin to the merged commit. The branch also requires +# the explicit-checkpoint-retention shardtree APIs, so `zcash/incrementalmerkletree` is git-patched +# too (mirror the rev the branch 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/zecrocks/librustzcash", + "https://github.com/zcash/incrementalmerkletree", +] 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..327f4c2 --- /dev/null +++ b/regtest-harness/tests/regtest_ironwood.rs @@ -0,0 +1,608 @@ +//! Ironwood (NU6.3) regtest end-to-end. Two 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. +//! +//! Requires the full ironwood toolchain - the official ironwood zebra RC (`zfnd/zebra:6.0.0-rc.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 `zecd` built +//! `--features ironwood` (no `--cfg` needed - upstream exposes the ironwood APIs unconditionally). +//! 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 (built --features ironwood) 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(); + + // 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 `--features ironwood` zecd 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; + } +} 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..ed8e0a1 100644 --- a/src/network.rs +++ b/src/network.rs @@ -84,6 +84,16 @@ 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) is only activated on the regtest chain for the ironwood build: the ironwood + // harness configures zebra with NU6.3 at `NU6_3_ACTIVATION_HEIGHT` (8), while the standard + // harness leaves it unset. Upstream now carries `nu6_3` as a stable (non-cfg-gated) field, so + // we set its *value* per build rather than gate the field's existence - the height must match + // zebra's `configured_activation_heights.nu6_3` and the harness so zecd commits transactions to + // the right consensus branch id. + #[cfg(feature = "ironwood")] + let nu63 = Some(BlockHeight::from_u32(8)); + #[cfg(not(feature = "ironwood"))] + let nu63 = None; ZNetwork::Regtest(LocalNetwork { overwinter: h, sapling: h, @@ -94,6 +104,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/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..200dc24 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 (behind `feature = "ironwood"`). + 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..ca5a11b 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, TransparentSpendPolicy}, 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,19 @@ 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. `PostNu6_3` is the ironwood circuit - a single + // `PostNu6_3` key serves BOTH the Orchard and Ironwood proof steps of a V6 transaction + // (devtool's `prove.rs` does the same), so the `ironwood` build uses it for every send. The + // ironwood feature is dev-only and targets post-NU6.3 chains, so it never needs the + // NU6.2 key; the default (released-equivalent) build stays on `FixedPostNu6_2`. + #[cfg(feature = "ironwood")] + let circuit = orchard::circuit::OrchardCircuitVersion::PostNu6_3; + #[cfg(not(feature = "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 +1930,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,10 +2799,13 @@ impl WalletActor { /// Whether sends on this wallet *may* use the cached-Orchard PCZT path (so prove and store are /// separable). True for the default Orchard-only wallet with `cache_proving_key` on. A /// Sapling-spending wallet (or `cache_proving_key` off) uses the fused path, which has no - /// prove/store seam - see [`Self::do_send_fused`]. NB this gates on the *wallet's* pools, not - /// the send's recipients: even when this is true, an individual send that pays a Sapling output - /// is still diverted to the fused path (`request_pays_sapling_output`), because the cached - /// path's extractor is handed no Sapling verifying key. + /// prove/store seam - see [`Self::do_send_fused`]. Ironwood (V6) sends also ride this path: + /// `create_pczt_from_proposal` builds Ironwood PCZTs (librustzcash#2543), and `prove_sign_pczt` + /// carries the paired `create_ironwood_proof` step, so an Ironwood send reuses the cached + /// `PostNu6_3` proving key exactly like an Orchard send reuses the `FixedPostNu6_2` key. 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) } @@ -2839,6 +2853,9 @@ 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`. + &TransparentSpendPolicy::ShieldedOnly, None, ) .map_err(|e| enrich_insufficient_funds(db, policy, classify_err(e)))?; @@ -2853,6 +2870,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 +2927,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 +2983,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 +3055,11 @@ 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. `ShieldedOnly` is the + // upstream default and preserves the prior fully-shielded selection behavior. + &TransparentSpendPolicy::ShieldedOnly, None, ) .map_err(|e| enrich_insufficient_funds(db, policy, classify_err(e)))?; @@ -3349,14 +3374,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 +3430,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 +4113,22 @@ 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. This is the Ironwood send's proving step: + // `create_pczt_from_proposal` builds the Ironwood PCZT (librustzcash#2543) and `do_send` routes + // the send here (the cached PCZT path), so `requires_ironwood_proof()` is true for a post-NU6.3 + // Orchard-pool spend and the Ironwood bundle is proved from the cached key - no per-send + // `ProvingKey::build()`. + #[cfg(feature = "ironwood")] + 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 +4164,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`). Only reachable under `feature = "ironwood"` + // (post-NU6.3, spending an ironwood note); the default build never produces an Ironwood bundle. + #[cfg(feature = "ironwood")] + { + 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 +4323,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 +4564,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 +4598,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 +4606,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 +4626,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 +4645,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 +4660,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 +4678,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 +4946,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..b645bb1 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. + #[cfg(feature = "ironwood")] + 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,29 @@ 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. + #[cfg(feature = "ironwood")] + { + 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; + #[cfg(feature = "ironwood")] + { + info.total_spendable += info.ironwood_spendable; + } } Ok(info) } @@ -182,6 +207,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 +669,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 +721,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 +744,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. + #[cfg(feature = "ironwood")] + 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 +799,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). + #[cfg(feature = "ironwood")] + 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 +859,34 @@ 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. + #[cfg(feature = "ironwood")] + 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 +895,7 @@ pub fn list_unspent(network: ZNetwork, wallet_dir: &Path) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result Date: Tue, 7 Jul 2026 16:37:11 -0500 Subject: [PATCH 02/13] Pin shardtree and incrementalmerkletree to our fork for the ironwood checkpoint-pruning fix Repoint shardtree and incrementalmerkletree to a currently-working zecrocks fork for the explicit-checkpoint-retention APIs plus a fix for a panic when pruning a checkpoint whose flags need clearing; this will be repointed upstream before release. Also run the ironwood NU6.3 regtest tier for PRs targeting the ironwood branch. Co-authored-by: AI --- .github/workflows/regtest.yml | 12 ++++++------ Cargo.lock | 4 ++-- Cargo.toml | 13 ++++++++----- deny.toml | 10 ++++++---- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index c06e09c..2dc7acd 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -256,12 +256,12 @@ jobs: # lockstep with the harness' NU6_3_ACTIVATION_HEIGHT, zecd's network::regtest(), and devtool's # regtest_params(). regtest-ironwood: - # schedule + manual dispatch, plus this ironwood-RC-update branch so iterating here auto-runs the - # NU6.3 e2e without a manual dispatch. `head_ref` matches it on the PR trigger (feature branches - # run via pull_request, where `github.ref` is the PR merge ref, not the branch); `ref` matches a - # direct push. Never on other branches' PRs - the emersonian/* lwd/devtool images are dev - # artifacts and must not gate anyone else's CI. Remove this branch keying when ironwood merges. - if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.head_ref == 'ironwood' || github.ref == 'refs/heads/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: diff --git a/Cargo.lock b/Cargo.lock index 8de90b7..118f16c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1534,7 +1534,7 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "incrementalmerkletree" version = "0.8.2" -source = "git+https://github.com/zcash/incrementalmerkletree?rev=decefc469dbf1e686b20b7e7dcaa7cb4f122125b#decefc469dbf1e686b20b7e7dcaa7cb4f122125b" +source = "git+https://github.com/zecrocks/incrementalmerkletree?rev=6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d#6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" dependencies = [ "either", ] @@ -2789,7 +2789,7 @@ dependencies = [ [[package]] name = "shardtree" version = "0.6.2" -source = "git+https://github.com/zcash/incrementalmerkletree?rev=decefc469dbf1e686b20b7e7dcaa7cb4f122125b#decefc469dbf1e686b20b7e7dcaa7cb4f122125b" +source = "git+https://github.com/zecrocks/incrementalmerkletree?rev=6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d#6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" dependencies = [ "bitflags", "either", diff --git a/Cargo.toml b/Cargo.toml index 272b8bd..8f3e20f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -223,8 +223,11 @@ zcash_proofs = { git = "https://github.com/zecrocks/librustzcash", rev = "409f34 zcash_transparent = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } # The `dw/ironwood-scan-model` line (our fork branch above) requires the explicit-checkpoint-retention # shardtree APIs (`ensure_retained`/`retained_checkpoints`/`remove_retained_checkpoint`) that aren't in -# a published shardtree yet, so it patches shardtree + incrementalmerkletree to `zcash/incrementalmerkletree` -# main by git rev. Mirror the exact rev the branch pins (its own `[patch]`), or `zcash_client_backend` -# fails to build against the crates.io shardtree. Drop when a shardtree release with these APIs is cut. -shardtree = { git = "https://github.com/zcash/incrementalmerkletree", rev = "decefc469dbf1e686b20b7e7dcaa7cb4f122125b" } -incrementalmerkletree = { git = "https://github.com/zcash/incrementalmerkletree", rev = "decefc469dbf1e686b20b7e7dcaa7cb4f122125b" } +# a published shardtree yet. We pin **our own fork - `zecrocks/incrementalmerkletree`** - rather than +# `zcash/incrementalmerkletree` main, because upstream `decefc46` panics when it prunes a checkpoint +# whose position flags need clearing; the fork is `decefc46` + one commit fixing that `clear_flags` +# checkpoint-pruning panic. Mirror this exact rev on both crates, or `zcash_client_backend` fails to +# build/run against the crates.io shardtree. Drop both once a shardtree release carrying these APIs +# and the fix is cut. +shardtree = { git = "https://github.com/zecrocks/incrementalmerkletree", rev = "6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" } +incrementalmerkletree = { git = "https://github.com/zecrocks/incrementalmerkletree", rev = "6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" } diff --git a/deny.toml b/deny.toml index 8c12200..8d03e85 100644 --- a/deny.toml +++ b/deny.toml @@ -57,10 +57,12 @@ unknown-git = "deny" # (rather than a contributor's) so the branch can't drift out from under us. Swap this back to # `https://github.com/zcash/librustzcash` when #2543 (or an equivalent create_pczt Ironwood build) # merges into `dw/ironwood-scan-model` and we repin to the merged commit. The branch also requires -# the explicit-checkpoint-retention shardtree APIs, so `zcash/incrementalmerkletree` is git-patched -# too (mirror the rev the branch 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. +# the explicit-checkpoint-retention shardtree APIs plus a `clear_flags` checkpoint-pruning panic fix, +# so shardtree + incrementalmerkletree are git-patched to our own fork +# `zecrocks/incrementalmerkletree` (upstream `decefc46` + one fix commit; 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 and the fix land on crates.io. allow-git = [ "https://github.com/zecrocks/librustzcash", - "https://github.com/zcash/incrementalmerkletree", + "https://github.com/zecrocks/incrementalmerkletree", ] From 62c332d85863b108856a1c19e885bb5639d9b9a7 Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Wed, 8 Jul 2026 10:38:52 -0500 Subject: [PATCH 03/13] Rebase the librustzcash fork onto the latest ironwood-scan-model Rebase the pin onto the latest ironwood-scan-model on the currently-working zecrocks fork of librustzcash, which will be repointed to mainline librustzcash before release, and bump the patch rev. - Route Ironwood sends through the fused build path (the ironwood_forces_fused gate) since upstream still rejects Ironwood PCZTs; input selection now takes SpendPolicy. - Add an ironwood receive-memo end-to-end regtest proving Ironwood-domain memo decryption. Co-authored-by: AI --- Cargo.lock | 26 +-- Cargo.toml | 73 ++++---- deny.toml | 6 +- regtest-harness/tests/regtest_ironwood.rs | 197 +++++++++++++++++++++- src/wallet/actor.rs | 71 +++++--- 5 files changed, 300 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 118f16c..38ad7f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -822,7 +822,7 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "equihash" version = "0.3.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "blake2b_simd", "corez", @@ -847,7 +847,7 @@ dependencies = [ [[package]] name = "f4jumble" version = "0.1.1" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "blake2b_simd", ] @@ -2004,7 +2004,7 @@ dependencies = [ [[package]] name = "pczt" version = "0.7.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "blake2b_simd", "bls12_381", @@ -3770,7 +3770,7 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" [[package]] name = "zcash_address" version = "0.13.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "bech32 0.11.1", "bs58", @@ -3783,7 +3783,7 @@ dependencies = [ [[package]] name = "zcash_client_backend" version = "0.23.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "base64 0.22.1", "bech32 0.11.1", @@ -3833,7 +3833,7 @@ dependencies = [ [[package]] name = "zcash_client_sqlite" version = "0.21.1" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "bip32", "bitflags", @@ -3879,7 +3879,7 @@ dependencies = [ [[package]] name = "zcash_encoding" version = "0.4.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "corez", "hex", @@ -3889,7 +3889,7 @@ dependencies = [ [[package]] name = "zcash_keys" version = "0.15.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "bech32 0.11.1", "bip32", @@ -3931,7 +3931,7 @@ dependencies = [ [[package]] name = "zcash_primitives" version = "0.29.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "blake2b_simd", "block-buffer 0.11.0-rc.3", @@ -3961,7 +3961,7 @@ dependencies = [ [[package]] name = "zcash_proofs" version = "0.29.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "bellman", "blake2b_simd", @@ -3983,7 +3983,7 @@ dependencies = [ [[package]] name = "zcash_protocol" version = "0.10.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "corez", "document-features", @@ -4021,7 +4021,7 @@ dependencies = [ [[package]] name = "zcash_transparent" version = "0.9.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "bip32", "bs58", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "zip321" version = "0.8.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=409f349086fa98c24601fcf0789684e29f22b35e#409f349086fa98c24601fcf0789684e29f22b35e" +source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "base64 0.22.1", "nom", diff --git a/Cargo.toml b/Cargo.toml index 8f3e20f..4fb6ef3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -154,30 +154,29 @@ incrementalmerkletree = "0.8" # line (zcash/librustzcash#2539), the same ironwood line the confirmed-working reference wallet # **zcash-devtool** (`ironwood-valar`) builds against. To avoid depending on a contributor's fork # staying current, zecd pins **our own fork branch - `zecrocks/librustzcash` `ironwood-scan-model`** -# - which is the current upstream `dw/ironwood-scan-model` HEAD **plus two cherry-picks of commits it -# still lacks**: -# 1. Gustavo Valverde's "build Ironwood PCZTs in create_pczt_from_proposal" (from librustzcash#2543, -# authorship preserved) - removes the old `pczt_parts.ironwood.is_some()` bailout and threads -# Ironwood spend/output metadata through `Updater::update_ironwood_with`, so -# `create_pczt_from_proposal` builds V6 Ironwood PCZTs and Ironwood sends ride the **cached -# proving-key PCZT path** (see `cached_pczt_path`/`prove_sign_pczt` in `actor.rs`) instead of the -# fused fallback. -# 2. "Add the ironwood_tree_retained_checkpoints table" (from librustzcash#2554) - creates the -# missing SQLite table the ironwood shardtree writes when it retains a durable anchor checkpoint -# (every `ANCHOR_RETENTION_INTERVAL` = 288 blocks post-NU6.3). Only the Sapling/Orchard tables -# existed; without it, any scan crossing a 288-boundary fails with -# `no such table: ironwood_tree_retained_checkpoints`. Guarded by `regtest_ironwood.rs`'s -# anchor-retention step. -# To maintain the branch: rebase our cherry-picks onto the latest upstream `dw/ironwood-scan-model`, -# push, bump the rev below; **drop each cherry-pick and repin straight to upstream as its PR (#2543 / -# #2554, or equivalents) merges.** The -# rebased upstream line already carries the rest: ironwood receive **memos** work -# (`decrypt_transaction` decrypts the Ironwood bundle), and the tip compile break is fixed. The pin -# carries the full ironwood API zecd uses (`ironwood_balance` for receives; `requires_ironwood_proof`/ -# `create_ironwood_proof` + `sign_ironwood` + the `PostNu6_3` circuit for sends; the ironwood -# `ChainState`; `Note::pool()`). NB the branch bumped **orchard to `0.15.0-pre.2`** and requires the -# explicit-checkpoint-retention **shardtree** APIs, so shardtree + incrementalmerkletree are also -# git-patched below to the exact `zcash/incrementalmerkletree` rev the branch pins. +# - which is the current upstream `dw/ironwood-scan-model` HEAD **plus one cherry-pick of a commit it +# still lacks**: "Backfill received-note memos for own self-sends" - a `zcash_client_sqlite` +# `put_blocks` pass that re-decrypts the wallet's own mined txs and fills a received note's NULL +# memo. Needed because a shielded payment to one's own address 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 the enhancement step that would backfill it is skipped for the wallet's +# own txs (their raw bytes are pre-stored -> classified Status-only). **This bites Ironwood hardest, +# where every write is a self-send** - so without it, a memo on an Ironwood *send* is silently lost. +# To maintain the branch: rebase this cherry-pick onto the latest upstream `dw/ironwood-scan-model`, +# push, bump the rev below; **drop it and repin straight to upstream once the self-send memo fix +# merges.** Two earlier cherry-picks were retired at this rebase: the `ironwood_tree_retained_checkpoints` +# table (librustzcash#2554) is now upstream, and the create_pczt Ironwood-build (librustzcash#2543) +# is intentionally NOT carried - upstream `create_pczt_from_proposal` still rejects Ironwood PCZTs +# (`step_uses_ironwood`), so zecd routes **Ironwood sends through the fused `create_proposed_transactions` +# path** (which upstream fully supports), via the `ironwood_forces_fused` gate in `actor.rs`. That +# trades the cached proving key for ~4.5 s/send keygen on Ironwood sends; drop the gate to reclaim +# the PCZT path once upstream removes its `step_uses_ironwood` 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 branch +# 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 **our own fork `zecrocks/incrementalmerkletree`** (upstream `decefc46` + that fix). # # 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}, @@ -208,19 +207,19 @@ 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/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -f4jumble = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -zcash_address = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -zcash_encoding = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -zcash_protocol = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -zip321 = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -pczt = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -zcash_keys = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -zcash_client_backend = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -zcash_client_sqlite = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -zcash_primitives = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -zcash_proofs = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } -zcash_transparent = { git = "https://github.com/zecrocks/librustzcash", rev = "409f349086fa98c24601fcf0789684e29f22b35e" } +equihash = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +f4jumble = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +zcash_address = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +zcash_encoding = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +zcash_protocol = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +zip321 = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +pczt = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +zcash_keys = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +zcash_client_backend = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +zcash_client_sqlite = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +zcash_primitives = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +zcash_proofs = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +zcash_transparent = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } # The `dw/ironwood-scan-model` line (our fork branch above) requires the explicit-checkpoint-retention # shardtree APIs (`ensure_retained`/`retained_checkpoints`/`remove_retained_checkpoint`) that aren't in # a published shardtree yet. We pin **our own fork - `zecrocks/incrementalmerkletree`** - rather than diff --git a/deny.toml b/deny.toml index 8d03e85..cdb43dd 100644 --- a/deny.toml +++ b/deny.toml @@ -53,10 +53,10 @@ unknown-git = "deny" # 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 our own fork branch, # `zecrocks/librustzcash` `ironwood-scan-model` = upstream `dw/ironwood-scan-model` HEAD + a -# cherry-pick of the create_pczt Ironwood-build commit from librustzcash#2543. We pin our own fork +# cherry-pick of the self-send received-note memo backfill (not yet upstream). We pin our own fork # (rather than a contributor's) so the branch can't drift out from under us. Swap this back to -# `https://github.com/zcash/librustzcash` when #2543 (or an equivalent create_pczt Ironwood build) -# merges into `dw/ironwood-scan-model` and we repin to the merged commit. The branch also requires +# `https://github.com/zcash/librustzcash` when the self-send memo fix merges into +# `dw/ironwood-scan-model` and we repin to the merged commit. 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 our own fork # `zecrocks/incrementalmerkletree` (upstream `decefc46` + one fix commit; mirror the rev the diff --git a/regtest-harness/tests/regtest_ironwood.rs b/regtest-harness/tests/regtest_ironwood.rs index 327f4c2..d6bc609 100644 --- a/regtest-harness/tests/regtest_ironwood.rs +++ b/regtest-harness/tests/regtest_ironwood.rs @@ -1,4 +1,4 @@ -//! Ironwood (NU6.3) regtest end-to-end. Two tests: +//! 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 @@ -6,6 +6,10 @@ //! 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 RC (`zfnd/zebra:6.0.0-rc.0`), //! a V6-parsing lightwalletd, an ironwood/regtest-aware `zcash-devtool` funder (its regtest `init` @@ -606,3 +610,194 @@ async fn regtest_ironwood_sapling_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 (built --features ironwood) 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(); + 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/src/wallet/actor.rs b/src/wallet/actor.rs index ca5a11b..3446537 100644 --- a/src/wallet/actor.rs +++ b/src/wallet/actor.rs @@ -15,7 +15,7 @@ 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, TransparentSpendPolicy}, + input_selection::{GreedyInputSelector, SpendPolicy}, propose_transfer, ConfirmationsPolicy, SpendingKeys, }; use zcash_client_backend::data_api::{ @@ -2799,15 +2799,41 @@ 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`]. Ironwood (V6) sends also ride this path: - /// `create_pczt_from_proposal` builds Ironwood PCZTs (librustzcash#2543), and `prove_sign_pczt` - /// carries the paired `create_ironwood_proof` step, so an Ironwood send reuses the cached - /// `PostNu6_3` proving key exactly like an Orchard send reuses the `FixedPostNu6_2` key. NB this - /// gates on the *wallet's* pools, not the send's recipients: even when this is true, an individual - /// send that pays a Sapling output is still diverted to the fused path (`request_pays_sapling_output`), - /// because the cached path's extractor is handed no Sapling verifying key. + /// prove/store seam - see [`Self::do_send_fused`]. Also forced off once NU6.3/Ironwood is active + /// (see [`Self::ironwood_forces_fused`]): 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). + #[cfg(feature = "ironwood")] + 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) + } + #[cfg(not(feature = "ironwood"))] + fn ironwood_forces_fused(&self) -> bool { + false } /// Whether a send should be pipelined: `[spend] pipeline_proving` on *and* the cached PCZT @@ -2854,8 +2880,10 @@ impl WalletActor { request, policy, // Shielded-only input selection (transparent UTXOs are spent via the separate - // lower-level `do_send_transparent` path), matching `do_send_fused`. - &TransparentSpendPolicy::ShieldedOnly, + // 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)))?; @@ -3057,9 +3085,10 @@ impl WalletActor { 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. `ShieldedOnly` is the - // upstream default and preserves the prior fully-shielded selection behavior. - &TransparentSpendPolicy::ShieldedOnly, + // 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)))?; @@ -4116,11 +4145,15 @@ fn prove_sign_pczt( // 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. This is the Ironwood send's proving step: - // `create_pczt_from_proposal` builds the Ironwood PCZT (librustzcash#2543) and `do_send` routes - // the send here (the cached PCZT path), so `requires_ironwood_proof()` is true for a post-NU6.3 - // Orchard-pool spend and the Ironwood bundle is proved from the cached key - no per-send - // `ProvingKey::build()`. + // `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. This step is the + // ready other half: once upstream lets the PCZT path build Ironwood bundles, dropping that gate + // routes Ironwood sends back through here - reusing the cached key instead of the fused path's + // per-send `ProvingKey::build()`. #[cfg(feature = "ironwood")] let prover = if prover.requires_ironwood_proof() { prover From 748768f4e524e78e01d1924e672201df914b2030 Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Thu, 9 Jul 2026 07:34:25 +0000 Subject: [PATCH 04/13] Pin shardtree and incrementalmerkletree to upstream main The zecrocks incrementalmerkletree fork existed only to carry the clear_flags checkpoint-pruning panic fix, which has now landed upstream. Repoint shardtree and incrementalmerkletree from the fork to zcash/incrementalmerkletree main (same crate versions, API-compatible) and drop the fork. The librustzcash pin is unchanged. Co-authored-by: AI --- Cargo.lock | 4 ++-- Cargo.toml | 19 +++++++++---------- deny.toml | 11 ++++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38ad7f3..0795b03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1534,7 +1534,7 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "incrementalmerkletree" version = "0.8.2" -source = "git+https://github.com/zecrocks/incrementalmerkletree?rev=6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d#6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" +source = "git+https://github.com/zcash/incrementalmerkletree?rev=7b23adb67044d2a9f4a68759f1e6340c3fc4735a#7b23adb67044d2a9f4a68759f1e6340c3fc4735a" dependencies = [ "either", ] @@ -2789,7 +2789,7 @@ dependencies = [ [[package]] name = "shardtree" version = "0.6.2" -source = "git+https://github.com/zecrocks/incrementalmerkletree?rev=6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d#6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" +source = "git+https://github.com/zcash/incrementalmerkletree?rev=7b23adb67044d2a9f4a68759f1e6340c3fc4735a#7b23adb67044d2a9f4a68759f1e6340c3fc4735a" dependencies = [ "bitflags", "either", diff --git a/Cargo.toml b/Cargo.toml index 4fb6ef3..f665481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -220,13 +220,12 @@ zcash_client_sqlite = { git = "https://github.com/zecrocks/librustzcash", rev = zcash_primitives = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } zcash_proofs = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } zcash_transparent = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -# The `dw/ironwood-scan-model` line (our fork branch above) requires the explicit-checkpoint-retention -# shardtree APIs (`ensure_retained`/`retained_checkpoints`/`remove_retained_checkpoint`) that aren't in -# a published shardtree yet. We pin **our own fork - `zecrocks/incrementalmerkletree`** - rather than -# `zcash/incrementalmerkletree` main, because upstream `decefc46` panics when it prunes a checkpoint -# whose position flags need clearing; the fork is `decefc46` + one commit fixing that `clear_flags` -# checkpoint-pruning panic. Mirror this exact rev on both crates, or `zcash_client_backend` fails to -# build/run against the crates.io shardtree. Drop both once a shardtree release carrying these APIs -# and the fix is cut. -shardtree = { git = "https://github.com/zecrocks/incrementalmerkletree", rev = "6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" } -incrementalmerkletree = { git = "https://github.com/zecrocks/incrementalmerkletree", rev = "6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" } +# 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, neither of which is in a published shardtree yet. Upstream +# `zcash/incrementalmerkletree` **main** now carries both (the panic fix landed upstream - no longer +# needs our `zecrocks/incrementalmerkletree` fork), so pin both crates to upstream main by git rev. +# Mirror this exact rev on both crates. Drop once a shardtree release carrying these APIs and the fix +# is cut. +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 cdb43dd..23c0ec2 100644 --- a/deny.toml +++ b/deny.toml @@ -58,11 +58,12 @@ unknown-git = "deny" # `https://github.com/zcash/librustzcash` when the self-send memo fix merges into # `dw/ironwood-scan-model` and we repin to the merged commit. 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 our own fork -# `zecrocks/incrementalmerkletree` (upstream `decefc46` + one fix commit; 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 and the fix land on crates.io. +# so shardtree + incrementalmerkletree are git-patched to `zcash/incrementalmerkletree` main (which now +# carries both - the panic fix landed upstream, so our `zecrocks/incrementalmerkletree` fork is no +# longer needed; 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 and the +# fix land on crates.io. allow-git = [ "https://github.com/zecrocks/librustzcash", - "https://github.com/zecrocks/incrementalmerkletree", + "https://github.com/zcash/incrementalmerkletree", ] From 15b1d41946d210f9b6f6a22e3ed73d7bcb75628d Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Thu, 9 Jul 2026 04:58:19 -0500 Subject: [PATCH 05/13] Compile ironwood unconditionally and activate it by network Drop the dev-only `ironwood` cargo feature and its 16 cfg gates; ironwood is now always compiled and activated purely by consensus height, so it is on for testnet (NU6.3 height 4_134_000), off for mainnet, and opt-in on regtest via the `ZECD_REGTEST_NU63_HEIGHT` env var, with no build flag. The proving-key cache is unconditionally the V5 Orchard circuit, since every post-NU6.3 send routes through the fused build path that builds its own key. This is an RC posture; the real release will pin published crates.io librustzcash instead of the git rev. Co-authored-by: AI --- .github/workflows/ci.yml | 23 ++----------- .github/workflows/regtest.yml | 15 ++++---- Cargo.toml | 17 ++++----- regtest-harness/tests/regtest_ironwood.rs | 11 +++--- src/network.rs | 21 ++++++------ src/sync/engine.rs | 2 +- src/wallet/actor.rs | 34 +++++++----------- src/wallet/read.rs | 42 ++++++++++------------- 8 files changed, 67 insertions(+), 98 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ac457c..2d6f7d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,24 +106,5 @@ jobs: - name: Check at MSRV run: cargo check --all-targets - # Guards the DEV-ONLY ironwood / NU6.3 build. The default tier above builds the pinned official - # zcash/librustzcash RC in released-equivalent mode (NU6.3 inactive); this job clippy-checks the - # `ironwood` feature so the ironwood scaffolding (scan/balance/send paths) can't silently bit-rot. - # Upstream exposes the ironwood APIs unconditionally, so the feature alone gates zecd's paths - no - # `zcash_unstable="nu6.3"` cfg is needed any more (the old valar-fork approach is retired). - ironwood: - runs-on: ubuntu-latest - 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 - with: - components: clippy - - - uses: Swatinem/rust-cache@v2 - - - name: Clippy (--features ironwood) - run: cargo clippy --features ironwood --all-targets -- -D warnings + # (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 2dc7acd..8e3a932 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -293,12 +293,11 @@ jobs: # Separate cache key: this build carries the ironwood feature + nu6.3 cfg. key: ironwood - # zecd must be built with the ironwood feature to scan ironwood notes and report the ironwood - # balance/pool. Official upstream (dw/ironwood-scan-model) exposes the ironwood wallet APIs - # unconditionally, so no `zcash_unstable="nu6.3"` cfg is needed any more - the feature alone - # gates zecd's ironwood paths. (The harness itself needs neither - it only spawns this binary.) - - name: Build zecd (release, --features ironwood) - run: cargo build --release --features ironwood --bin zecd + # 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 @@ -327,6 +326,10 @@ jobs: 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. diff --git a/Cargo.toml b/Cargo.toml index f665481..6df83bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,16 +119,13 @@ 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"] -# Ironwood (NU6.3 / V6). DEV-ONLY: builds against the official zcash/librustzcash rev that -# zcash-devtool's `ironwood-valar` branch uses (see `[patch.crates-io]`). Gates zecd's own ironwood -# paths: the `PostNu6_3` proving key + the ironwood proof step in the send path, the NU6.3 regtest -# activation height, and `pool == "ironwood"` labelling. Build with just `cargo build --features -# ironwood` - upstream exposes the ironwood types unconditionally, so NO `--cfg zcash_unstable="nu6.3"` -# RUSTFLAG is needed (unlike the old valar fork). The default build (feature off) stays -# released-equivalent: NU6.3 is inactive and no ironwood paths run. Sends and receives both work on -# the pinned rev (matching zcash-devtool's ironwood-valar), via `AccountBalance::ironwood_balance()` -# for receives and the ironwood proof step for sends. -ironwood = [] +# 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" diff --git a/regtest-harness/tests/regtest_ironwood.rs b/regtest-harness/tests/regtest_ironwood.rs index d6bc609..75419ed 100644 --- a/regtest-harness/tests/regtest_ironwood.rs +++ b/regtest-harness/tests/regtest_ironwood.rs @@ -13,8 +13,9 @@ //! //! Requires the full ironwood toolchain - the official ironwood zebra RC (`zfnd/zebra:6.0.0-rc.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 `zecd` built -//! `--features ironwood` (no `--cfg` needed - upstream exposes the ironwood APIs unconditionally). +//! 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. @@ -108,7 +109,7 @@ async fn regtest_ironwood_receive_and_orchard_send() { zebrad.generate_blocks(6).await.expect("confirm shield"); funder.sync(lwd.grpc_port).expect("funder sync (shielded)"); - // 5. zecd (built --features ironwood) against zebra; get its unified address. + // 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 @@ -192,7 +193,7 @@ async fn regtest_ironwood_receive_and_orchard_send() { // 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 `--features ironwood` zecd sums + // 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; @@ -674,7 +675,7 @@ async fn regtest_ironwood_receive_memo() { zebrad.generate_blocks(6).await.expect("confirm shield"); funder.sync(lwd.grpc_port).expect("funder sync (shielded)"); - // 5-6. Start zecd (built --features ironwood) and wait until it is caught up. + // 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 diff --git a/src/network.rs b/src/network.rs index ed8e0a1..4a581ef 100644 --- a/src/network.rs +++ b/src/network.rs @@ -84,16 +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) is only activated on the regtest chain for the ironwood build: the ironwood - // harness configures zebra with NU6.3 at `NU6_3_ACTIVATION_HEIGHT` (8), while the standard - // harness leaves it unset. Upstream now carries `nu6_3` as a stable (non-cfg-gated) field, so - // we set its *value* per build rather than gate the field's existence - the height must match - // zebra's `configured_activation_heights.nu6_3` and the harness so zecd commits transactions to - // the right consensus branch id. - #[cfg(feature = "ironwood")] - let nu63 = Some(BlockHeight::from_u32(8)); - #[cfg(not(feature = "ironwood"))] - let nu63 = None; + // 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, diff --git a/src/sync/engine.rs b/src/sync/engine.rs index 200dc24..4e032b7 100644 --- a/src/sync/engine.rs +++ b/src/sync/engine.rs @@ -93,7 +93,7 @@ pub async fn update_subtree_roots( // 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 (behind `feature = "ironwood"`). + // later adds an Ironwood subtree-root writer, reinstate a pass here (gated on NU6.3 being active). Ok(()) } diff --git a/src/wallet/actor.rs b/src/wallet/actor.rs index 3446537..4538a4f 100644 --- a/src/wallet/actor.rs +++ b/src/wallet/actor.rs @@ -82,14 +82,12 @@ impl ProvingKeyCache { /// 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. `PostNu6_3` is the ironwood circuit - a single - // `PostNu6_3` key serves BOTH the Orchard and Ironwood proof steps of a V6 transaction - // (devtool's `prove.rs` does the same), so the `ironwood` build uses it for every send. The - // ironwood feature is dev-only and targets post-NU6.3 chains, so it never needs the - // NU6.2 key; the default (released-equivalent) build stays on `FixedPostNu6_2`. - #[cfg(feature = "ironwood")] - let circuit = orchard::circuit::OrchardCircuitVersion::PostNu6_3; - #[cfg(not(feature = "ironwood"))] + // 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(circuit), @@ -2821,7 +2819,6 @@ impl WalletActor { /// 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). - #[cfg(feature = "ironwood")] fn ironwood_forces_fused(&self) -> bool { use zcash_protocol::consensus::{NetworkUpgrade, Parameters}; self.tip_height @@ -2831,10 +2828,6 @@ impl WalletActor { }) .unwrap_or(false) } - #[cfg(not(feature = "ironwood"))] - fn ironwood_forces_fused(&self) -> bool { - false - } /// Whether a send should be pipelined: `[spend] pipeline_proving` on *and* the cached PCZT /// path applies (only that path can prove off the actor and store back on it). @@ -4150,11 +4143,10 @@ fn prove_sign_pczt( // 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. This step is the - // ready other half: once upstream lets the PCZT path build Ironwood bundles, dropping that gate - // routes Ironwood sends back through here - reusing the cached key instead of the fused path's - // per-send `ProvingKey::build()`. - #[cfg(feature = "ironwood")] + // 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) @@ -4201,9 +4193,9 @@ fn prove_sign_pczt( // 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`). Only reachable under `feature = "ironwood"` - // (post-NU6.3, spending an ironwood note); the default build never produces an Ironwood bundle. - #[cfg(feature = "ironwood")] + // `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 { diff --git a/src/wallet/read.rs b/src/wallet/read.rs index b645bb1..2b169ba 100644 --- a/src/wallet/read.rs +++ b/src/wallet/read.rs @@ -26,8 +26,8 @@ pub struct BalanceInfo { 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. - #[cfg(feature = "ironwood")] + /// 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. @@ -86,25 +86,21 @@ pub fn balance( // `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. - #[cfg(feature = "ironwood")] - { - 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; - #[cfg(feature = "ironwood")] - { - info.total_spendable += info.ironwood_spendable; + // (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.ironwood_spendable; } Ok(info) } @@ -779,7 +775,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). - #[cfg(feature = "ironwood")] for note in notes.ironwood() { let value = note .note_value() @@ -878,8 +873,7 @@ pub fn list_unspent(network: ZNetwork, wallet_dir: &Path) -> anyhow::Result Date: Thu, 9 Jul 2026 09:00:00 -0500 Subject: [PATCH 06/13] Set version to 0.5.0-rc0 Also make the numeric version encoding take each component's leading digit run, so the -rc0 pre-release suffix parses instead of panicking. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/rpc/network.rs | 33 ++++++++++++++++++++------------- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0795b03..a8ea36e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4044,7 +4044,7 @@ dependencies = [ [[package]] name = "zecd" -version = "0.4.3" +version = "0.5.0-rc0" dependencies = [ "age", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 6df83bc..e75ccd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zecd" -version = "0.4.3" +version = "0.5.0-rc0" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" 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); } } From 56c1d06510bcd87246d4c220102214b5b22a942c Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Thu, 9 Jul 2026 10:00:00 -0500 Subject: [PATCH 07/13] Release 0.5.0-rc1 --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9ae64f..b4c6955 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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-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 +168,7 @@ 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-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 a8ea36e..11c0ba3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4044,7 +4044,7 @@ dependencies = [ [[package]] name = "zecd" -version = "0.5.0-rc0" +version = "0.5.0-rc1" dependencies = [ "age", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index e75ccd9..9ed328f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zecd" -version = "0.5.0-rc0" +version = "0.5.0-rc1" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" From c2742dea9084b5afe286067d622afdeb5ce7ad1c Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Thu, 9 Jul 2026 11:00:00 -0500 Subject: [PATCH 08/13] Pin the testnet compose stack to an ironwood-capable zebra The bundled stack pinned zebra 5.0.0, which carries no NU6.3, so the testnet deployment could never activate ironwood. Point the testnet base at the ironwood release candidate and pin mainnet back to the stable release, since ironwood is testnet-only. Co-authored-by: AI --- deploy/docker-compose.mainnet.yml | 8 ++++++-- deploy/docker-compose.yml | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/deploy/docker-compose.mainnet.yml b/deploy/docker-compose.mainnet.yml index 419896f..18954be 100644 --- a/deploy/docker-compose.mainnet.yml +++ b/deploy/docker-compose.mainnet.yml @@ -1,6 +1,7 @@ # 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 -# unchanged, so the same workflow applies - just add `-f docker-compose.mainnet.yml`: +# this override swaps each service's config file for its mainnet variant and pins zebra back to a +# stable release. 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 # docker compose -f docker-compose.yml -f docker-compose.mainnet.yml run --rm zecd init --wallet default @@ -11,6 +12,9 @@ services: zebra: + # Ironwood (NU6.3) is testnet-only, so mainnet stays on the stable zebra release instead of the + # ironwood release candidate the testnet stack pins. + image: zfnd/zebra:5.0.0 volumes: - ./zebrad.mainnet.toml:/etc/zebra/zebrad.toml:ro diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index cce5471..14059c9 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -16,7 +16,9 @@ services: zebra: - image: zfnd/zebra:6.0.0 # pin to a verified release (zebra tags have no 'v' prefix) + # Ironwood (NU6.3) needs an ironwood-capable zebrad; testnet activates NU6.3 at height 4134000. + # Mainnet pins back to a stable release (see docker-compose.mainnet.yml). + image: zfnd/zebra:6.0.0-rc.0 # pin to a verified release (zebra tags have no 'v' prefix) restart: unless-stopped command: ["zebrad", "-c", "/etc/zebra/zebrad.toml", "start"] volumes: From 065c1af9ece132795c971056a19bd81f47067407 Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Fri, 10 Jul 2026 10:09:55 -0500 Subject: [PATCH 09/13] Assert getnewaddress returns a valid address on an NU6.3-active chain Add an ironwood regtest assertion that `getnewaddress` yields a non-empty, valid unified address once NU6.3 is active, guarding the address path on a post-activation chain. Co-authored-by: AI --- regtest-harness/tests/regtest_ironwood.rs | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/regtest-harness/tests/regtest_ironwood.rs b/regtest-harness/tests/regtest_ironwood.rs index 75419ed..6e2572a 100644 --- a/regtest-harness/tests/regtest_ironwood.rs +++ b/regtest-harness/tests/regtest_ironwood.rs @@ -119,6 +119,21 @@ async fn regtest_ironwood_receive_and_orchard_send() { .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; @@ -685,6 +700,21 @@ async fn regtest_ironwood_receive_memo() { .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 From 0f9681fc454bcd6d439c324d6d22afae261b314e Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Sat, 11 Jul 2026 03:56:37 -0500 Subject: [PATCH 10/13] Move off the librustzcash fork to upstream Pin the librustzcash set by git rev to the official zcash/librustzcash ironwood line (dw/ironwood-scan-model) instead of the zecrocks fork, and drop the self-send received-note memo backfill cherry-pick that only the fork carried (it is being submitted upstream separately). The ironwood wallet crates are not published to crates.io yet, so the set stays git-pinned to upstream until they land. Co-authored-by: AI --- Cargo.lock | 26 +++++++-------- Cargo.toml | 98 ++++++++++++++++++++++++++++-------------------------- deny.toml | 19 +++++------ 3 files changed, 71 insertions(+), 72 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11c0ba3..550eb73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -822,7 +822,7 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "equihash" version = "0.3.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "blake2b_simd", "corez", @@ -847,7 +847,7 @@ dependencies = [ [[package]] name = "f4jumble" version = "0.1.1" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "blake2b_simd", ] @@ -2004,7 +2004,7 @@ dependencies = [ [[package]] name = "pczt" version = "0.7.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "blake2b_simd", "bls12_381", @@ -3770,7 +3770,7 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" [[package]] name = "zcash_address" version = "0.13.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "bech32 0.11.1", "bs58", @@ -3783,7 +3783,7 @@ dependencies = [ [[package]] name = "zcash_client_backend" version = "0.23.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "base64 0.22.1", "bech32 0.11.1", @@ -3833,7 +3833,7 @@ dependencies = [ [[package]] name = "zcash_client_sqlite" version = "0.21.1" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "bip32", "bitflags", @@ -3879,7 +3879,7 @@ dependencies = [ [[package]] name = "zcash_encoding" version = "0.4.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "corez", "hex", @@ -3889,7 +3889,7 @@ dependencies = [ [[package]] name = "zcash_keys" version = "0.15.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "bech32 0.11.1", "bip32", @@ -3931,7 +3931,7 @@ dependencies = [ [[package]] name = "zcash_primitives" version = "0.29.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "blake2b_simd", "block-buffer 0.11.0-rc.3", @@ -3961,7 +3961,7 @@ dependencies = [ [[package]] name = "zcash_proofs" version = "0.29.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "bellman", "blake2b_simd", @@ -3983,7 +3983,7 @@ dependencies = [ [[package]] name = "zcash_protocol" version = "0.10.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "corez", "document-features", @@ -4021,7 +4021,7 @@ dependencies = [ [[package]] name = "zcash_transparent" version = "0.9.0-pre.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +source = "git+https://github.com/zcash/librustzcash?rev=1105d8203eb595c28c39929fba65569de9696851#1105d8203eb595c28c39929fba65569de9696851" dependencies = [ "bip32", "bs58", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "zip321" version = "0.8.0" -source = "git+https://github.com/zecrocks/librustzcash?rev=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" +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 9ed328f..c51d8c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,41 +139,42 @@ incrementalmerkletree = "0.8" # 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 (replacing the old valargroup fork). The -# ironwood / NU6.3 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`, +# 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 zecd leans on (`zcash_client_backend`/`zcash_client_sqlite`) and their -# siblings (`pczt`/`zip321`/`zcash_encoding`) are NOT yet published with the ironwood changes. -# `main` itself still has the wallet-side ironwood work unfinished (`client_sqlite` has -# `ShieldedPool::Ironwood => todo!(...)`), so we track upstream's **`dw/ironwood-scan-model`** -# line (zcash/librustzcash#2539), the same ironwood line the confirmed-working reference wallet -# **zcash-devtool** (`ironwood-valar`) builds against. To avoid depending on a contributor's fork -# staying current, zecd pins **our own fork branch - `zecrocks/librustzcash` `ironwood-scan-model`** -# - which is the current upstream `dw/ironwood-scan-model` HEAD **plus one cherry-pick of a commit it -# still lacks**: "Backfill received-note memos for own self-sends" - a `zcash_client_sqlite` -# `put_blocks` pass that re-decrypts the wallet's own mined txs and fills a received note's NULL -# memo. Needed because a shielded payment to one's own address 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 the enhancement step that would backfill it is skipped for the wallet's -# own txs (their raw bytes are pre-stored -> classified Status-only). **This bites Ironwood hardest, -# where every write is a self-send** - so without it, a memo on an Ironwood *send* is silently lost. -# To maintain the branch: rebase this cherry-pick onto the latest upstream `dw/ironwood-scan-model`, -# push, bump the rev below; **drop it and repin straight to upstream once the self-send memo fix -# merges.** Two earlier cherry-picks were retired at this rebase: the `ironwood_tree_retained_checkpoints` -# table (librustzcash#2554) is now upstream, and the create_pczt Ironwood-build (librustzcash#2543) -# is intentionally NOT carried - upstream `create_pczt_from_proposal` still rejects Ironwood PCZTs -# (`step_uses_ironwood`), so zecd routes **Ironwood sends through the fused `create_proposed_transactions` -# path** (which upstream fully supports), via the `ironwood_forces_fused` gate in `actor.rs`. That -# trades the cached proving key for ~4.5 s/send keygen on Ironwood sends; drop the gate to reclaim -# the PCZT path once upstream removes its `step_uses_ironwood` guard. +# `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 branch +# 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 **our own fork `zecrocks/incrementalmerkletree`** (upstream `decefc46` + that fix). +# 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}, @@ -204,25 +205,26 @@ 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/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -f4jumble = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -zcash_address = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -zcash_encoding = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -zcash_protocol = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -zip321 = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -pczt = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -zcash_keys = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -zcash_client_backend = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -zcash_client_sqlite = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -zcash_primitives = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -zcash_proofs = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } -zcash_transparent = { git = "https://github.com/zecrocks/librustzcash", rev = "71da9dddc194f8adf003b14c7957d03a4836c3cd" } +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, neither of which is in a published shardtree yet. Upstream -# `zcash/incrementalmerkletree` **main** now carries both (the panic fix landed upstream - no longer -# needs our `zecrocks/incrementalmerkletree` fork), so pin both crates to upstream main by git rev. -# Mirror this exact rev on both crates. Drop once a shardtree release carrying these APIs and the fix -# is cut. +# 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 23c0ec2..e034415 100644 --- a/deny.toml +++ b/deny.toml @@ -51,19 +51,16 @@ wildcards = "deny" unknown-registry = "deny" unknown-git = "deny" # 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 our own fork branch, -# `zecrocks/librustzcash` `ironwood-scan-model` = upstream `dw/ironwood-scan-model` HEAD + a -# cherry-pick of the self-send received-note memo backfill (not yet upstream). We pin our own fork -# (rather than a contributor's) so the branch can't drift out from under us. Swap this back to -# `https://github.com/zcash/librustzcash` when the self-send memo fix merges into -# `dw/ironwood-scan-model` and we repin to the merged commit. The branch also requires +# 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 - the panic fix landed upstream, so our `zecrocks/incrementalmerkletree` fork is no -# longer needed; 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 and the -# fix land on crates.io. +# 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/zecrocks/librustzcash", + "https://github.com/zcash/librustzcash", "https://github.com/zcash/incrementalmerkletree", ] From 16a0c81bc68e8832799b8e3989e6626663a2bb95 Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Sun, 12 Jul 2026 09:08:21 +0000 Subject: [PATCH 11/13] Pin the ironwood stack to zebra 6.0.0 Zebra 6.0.0 is released and serves both networks (Orchard on mainnet, ironwood on testnet), so bump the testnet compose base, the regtest ironwood tier, and the workflow default from the 6.0.0-rc.0 candidate to 6.0.0. The mainnet overlay no longer needs a separate zebra pin and reuses the base image. Co-authored-by: AI --- .github/workflows/regtest.yml | 10 +++++----- deploy/docker-compose.mainnet.yml | 9 +++------ deploy/docker-compose.yml | 6 +++--- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index 8e3a932..45de6eb 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -41,7 +41,7 @@ on: required: false default: "" zebra-ironwood-image: - description: "Ironwood tier: NU6.3-capable zebrad image (default zfnd/zebra:6.0.0-rc.0); blank = the job default" + description: "Ironwood tier: NU6.3-capable zebrad image (default zfnd/zebra:6.0.0); blank = the job default" required: false default: "" lwd-ironwood-image: @@ -245,7 +245,7 @@ jobs: # 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 RC (`zfnd/zebra:6.0.0-rc.0`) plus a still-dev V6-aware lightwalletd and a regtest-aware + # 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"`. # @@ -265,16 +265,16 @@ jobs: runs-on: ubuntu-latest name: regtest (ironwood / NU6.3) env: - # zebrad is now the OFFICIAL ironwood release candidate on Docker Hub (`zfnd/zebra:6.0.0-rc.0`), + # 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-rc.0); + # 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-rc.0' }} + 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: diff --git a/deploy/docker-compose.mainnet.yml b/deploy/docker-compose.mainnet.yml index 18954be..6becf44 100644 --- a/deploy/docker-compose.mainnet.yml +++ b/deploy/docker-compose.mainnet.yml @@ -1,7 +1,7 @@ # 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 and pins zebra back to a -# stable release. Ports and wiring are unchanged, so the same workflow applies - just add -# `-f docker-compose.mainnet.yml`: +# 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 # docker compose -f docker-compose.yml -f docker-compose.mainnet.yml run --rm zecd init --wallet default @@ -12,9 +12,6 @@ services: zebra: - # Ironwood (NU6.3) is testnet-only, so mainnet stays on the stable zebra release instead of the - # ironwood release candidate the testnet stack pins. - image: zfnd/zebra:5.0.0 volumes: - ./zebrad.mainnet.toml:/etc/zebra/zebrad.toml:ro diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 14059c9..f1adfed 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -16,9 +16,9 @@ services: zebra: - # Ironwood (NU6.3) needs an ironwood-capable zebrad; testnet activates NU6.3 at height 4134000. - # Mainnet pins back to a stable release (see docker-compose.mainnet.yml). - image: zfnd/zebra:6.0.0-rc.0 # pin to a verified release (zebra tags have no 'v' prefix) + # 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"] volumes: From 2101f1417ff8ea73fdcac07f083f54583e3183f6 Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Sun, 12 Jul 2026 09:08:43 +0000 Subject: [PATCH 12/13] Update the ironwood regtest doc to zebra 6.0.0 Co-authored-by: AI --- regtest-harness/tests/regtest_ironwood.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/regtest-harness/tests/regtest_ironwood.rs b/regtest-harness/tests/regtest_ironwood.rs index 6e2572a..c07024b 100644 --- a/regtest-harness/tests/regtest_ironwood.rs +++ b/regtest-harness/tests/regtest_ironwood.rs @@ -11,7 +11,7 @@ //! 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 RC (`zfnd/zebra:6.0.0-rc.0`), +//! 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` From 8d9820ccebd8f4380eaa9762641b2d391f55646a Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Sun, 12 Jul 2026 09:11:23 +0000 Subject: [PATCH 13/13] Release 0.5.0-rc2 Rebase the ironwood line onto 0.4.3 and repoint librustzcash to the upstream zcash/librustzcash ironwood branch, retiring the interim zecrocks fork. Ironwood now carries every 0.4.x fix through 0.4.3. Co-authored-by: AI --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c6955..77f044d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 @@ -168,6 +174,7 @@ 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 diff --git a/Cargo.lock b/Cargo.lock index 550eb73..36a0b1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4044,7 +4044,7 @@ dependencies = [ [[package]] name = "zecd" -version = "0.5.0-rc1" +version = "0.5.0-rc2" dependencies = [ "age", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index c51d8c9..d8cad0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zecd" -version = "0.5.0-rc1" +version = "0.5.0-rc2" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0"