diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e593b5f..14b5984 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,10 +31,15 @@ jobs: - uses: Swatinem/rust-cache@v2 - # Formatting is host-independent; run it once, on Linux. + # Formatting is host-independent; run it once, on Linux. The regtest + # harness is a separate workspace (see regtest-harness/README.md), so it + # gets its own fmt pass; its compile/clippy/test runs live in the + # Regtest E2E workflow, which triggers on the paths that affect it. - name: cargo fmt if: runner.os == 'Linux' - run: cargo fmt --all --check + run: | + cargo fmt --all --check + cargo fmt --all --check --manifest-path regtest-harness/Cargo.toml - name: cargo clippy run: cargo clippy --workspace --all-targets -- -D warnings diff --git a/.github/workflows/regtest-ironwood.yml b/.github/workflows/regtest-ironwood.yml new file mode 100644 index 0000000..f6d595c --- /dev/null +++ b/.github/workflows/regtest-ironwood.yml @@ -0,0 +1,119 @@ +name: Regtest E2E (Ironwood) + +# Ironwood (NU6.3) live tier. Same harness as `regtest.yml`, but against the +# Ironwood stack: `zfnd/zebra:6.0.0-rc.0` (the official Ironwood zebra RC) with +# development lightwalletd + zcash-devtool images (emersonian/*), all pinned by +# digest, and `ZKV_REGTEST_NU6_3=1` so the harness emits zebra's `"NU6.3"` +# activation-height key and the devtool funder's `--activation-heights` carry +# `nu6_3` (stock zebra rejects the unknown `"NU6.3"` key, so this can only run +# against an Ironwood-capable zebra). +# +# Gated to schedule + manual dispatch + the Ironwood feature branch's PRs. The +# lightwalletd/devtool images are dev artifacts, so this job must never gate an +# unrelated PR's CI; on any other PR the job is skipped (see the job `if:`). +# +# All three NU6.3 activation heights must agree (or consensus diverges): +# - zebra: `"NU6.3" = 8` (harness `zebrad_toml`, gated on ZKV_REGTEST_NU6_3) +# - wallet: `network::REGTEST_NU6_3_HEIGHT = 8` (crates/zkv/src/network.rs) +# - funder: devtool `--activation-heights` `nu6_3 = 8` (harness `Funder::init`) + +on: + workflow_dispatch: + schedule: + # Weekly, off-peak and on an odd minute distinct from regtest.yml's cron. + - cron: "31 7 * * 2" + pull_request: + paths: + - "regtest-harness/**" + - "crates/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/regtest-ironwood.yml" + +concurrency: + group: regtest-ironwood-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +# NB: no env var may start with `ZEBRA_` — zebrad parses `ZEBRA_*` as config +# overrides and exits on unknown fields (the harness also scrubs them). +env: + ZEBRAD_IMAGE: "zfnd/zebra:6.0.0-rc.0" + LWD_IMAGE: "emersonian/zcash-lwd-ironwood@sha256:5f63b64ed788330ad8fe859fbbb334ceea844cf8a376318e7e25fbd3351b2f9f" + DEVTOOL_IMAGE: "emersonian/zcash-devtool@sha256:fd95d8b432f47d3eec6e5f1eca0bdfbb8daa094c28742d230bec3ac47957bd6c" + +jobs: + regtest-ironwood: + runs-on: ubuntu-latest + # On a pull request, run only for the Ironwood branch: the emersonian/* + # dev images must not gate anyone else's PR. schedule / workflow_dispatch + # always run. + if: >- + github.event_name != 'pull_request' || + github.head_ref == 'claude/zkv-ironwood-support-5s6bfn' + name: regtest-ironwood + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + regtest-harness + + # Release build: writes prove Orchard spends, and debug proving is >20s + # per transaction. The lean CLI build skips the GUI asset/licensing steps. + - name: Build zkv (release, lean CLI) + run: cargo build --release -p zkv --bin zkv --no-default-features --features cli,transparent-inputs + + # Extract zebrad + lightwalletd + zcash-devtool from their Docker images. + # The harness drives all three as subprocesses (it reads ZEBRAD_BIN / + # LIGHTWALLETD_BIN / DEVTOOL_BIN), so we only need the binaries. + - name: Provision 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_IMAGE" zebrad + extract "$LWD_IMAGE" lightwalletd + extract "$DEVTOOL_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" + + # The harness is its own workspace, so the root ci.yml's clippy never + # sees it; lint it here where it is already being compiled. + - name: Clippy (harness) + run: cargo clippy --all-targets --locked --manifest-path regtest-harness/Cargo.toml -- -D warnings + + - name: Run regtest end-to-end harness (Ironwood) + env: + ZKV_BIN: ${{ github.workspace }}/target/release/zkv + # Enable the NU6.3 (Ironwood) activation-height wiring on all three + # sides (harness zebrad config + devtool funder --activation-heights). + ZKV_REGTEST_NU6_3: "1" + # DEVTOOL_BIN / ZEBRAD_BIN / LIGHTWALLETD_BIN are exported above. + ZEBRAD_STDERR: ${{ runner.temp }}/zebrad.log + # --locked build against the committed lock; single-threaded so the + # zebrad+lightwalletd stack plus Orchard proving don't contend on a + # 2-core runner; --no-fail-fast runs every test binary. + run: cargo test --locked --manifest-path regtest-harness/Cargo.toml --test regtest_unfunded --test regtest_kv --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/.github/workflows/regtest.yml b/.github/workflows/regtest.yml new file mode 100644 index 0000000..6714458 --- /dev/null +++ b/.github/workflows/regtest.yml @@ -0,0 +1,209 @@ +name: Regtest E2E + +# Live tier: brings up a zebra (Regtest) node with a lightwalletd in front of +# it and drives the real zkv CLI binary through the full database lifecycle +# (init, fund, INIT, set/get/del, history, roles, watch-only, shallow); see +# regtest-harness/. It also builds zcash-devtool (regtest-enabled) as the +# funding wallet, used to shield a mined transparent coinbase into Orchard and +# fund the zkv wallet (regtest can't mine an Orchard coinbase directly). +# No zingo-infra: the harness mines via zebrad's Regtest-only `generate` RPC, +# so it has no zebra crate dependency and works against any zebrad release. +# zebrad + lightwalletd are extracted from their official Docker images. +# +# Runs on PRs and pushes to main against the pinned zebra image. The weekly +# schedule tests BOTH the pinned image (re-validating the baseline) and +# zfnd/zebra:latest (an early-warning canary for upstream zebra changes). +# +# The pattern (and much of the harness) follows zecd's Regtest E2E workflow. + +on: + workflow_dispatch: + inputs: + zebra-image: + description: "zfnd/zebra image tag to test against (e.g. 5.0.0, latest)" + required: false + default: "" + schedule: + # Weekly canary against zfnd/zebra:latest (Tuesdays 06:43 UTC; an off-peak + # odd minute). + - cron: "43 6 * * 2" + push: + # Mainline only: feature branches (incl. claude/**) are covered by the + # pull_request trigger below, so listing them here too would run every job + # twice on a PR push. + branches: [main, master] + paths: + - "regtest-harness/**" + - "crates/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/regtest.yml" + pull_request: + paths: + - "regtest-harness/**" + - "crates/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/regtest.yml" + +# The pinned zebra image used by PR/push runs; the schedule runs `latest` as +# well, and a manual dispatch can name any tag. Bumping zebra = changing this +# one value (the harness drives zebrad purely over JSON-RPC, so nothing else +# needs to move in lockstep). +# +# IMPORTANT: no workflow env var may start with `ZEBRA_`; zebrad parses +# ZEBRA_*-prefixed environment variables as config overrides and exits with +# "unknown field" on ones it doesn't recognise. (The harness also scrubs +# ZEBRA_* before spawning zebrad, as a second line of defence.) +env: + ZEBRAD_IMAGE_PINNED: "5.0.0" + +concurrency: + group: regtest-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + regtest: + runs-on: ubuntu-latest + # The Ironwood feature branch activates NU6.3 on its regtest chain, which + # this tier's stock zebra doesn't know, so the two diverge past the NU6.3 + # height and the e2e can't pass here. That branch's live tier runs in + # `regtest-ironwood.yml` (Ironwood zebra + dev lwd/devtool images) instead, + # so skip the stock tier on its PRs. Every other PR / push / schedule runs + # normally. + if: >- + github.event_name != 'pull_request' || + github.head_ref != 'claude/zkv-ironwood-support-5s6bfn' + # NB: new harness test files must be added to the matrix `tests` list here + # (it enumerates --test targets; nothing runs them implicitly). + # + # The `image` dimension is "event" except on the weekly schedule, where + # both the pinned baseline and `latest` run ("event" resolves the tag from + # the trigger: dispatch input or the pinned default). + strategy: + fail-fast: false + matrix: + suite: [zkv] + image: ${{ github.event_name == 'schedule' && fromJSON('["pinned", "latest"]') || fromJSON('["event"]') }} + include: + - suite: zkv + tests: --test regtest_unfunded --test regtest_kv + name: regtest (${{ matrix.suite }}, ${{ matrix.image }}) + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + regtest-harness + zcash-devtool + + # Release build: writes prove Orchard spends, and debug proving is >20s + # per transaction; release keeps the harness snappy. The lean CLI build + # skips the GUI asset/licensing build steps. + - name: Build zkv (release, lean CLI) + run: cargo build --release -p zkv --bin zkv --no-default-features --features cli,transparent-inputs + + # The funding wallet: zcash-devtool with regtest support + # (PR zecrocks/zcash-devtool#1). It shields a mined transparent coinbase + # into Orchard and sends it to the zkv wallet, since regtest can't mine + # an Orchard coinbase a shielded wallet would scan. + # Pinned to the same commit SHA zecd's regtest workflow uses, so the + # build can't change or vanish under us. + - name: Checkout zcash-devtool (funder) + uses: actions/checkout@v4 + with: + repository: zecrocks/zcash-devtool + ref: 7ca3ce28ed9f606cdb1e1a6c8ae087cb2c5a2973 + path: zcash-devtool + + # Cache the funder binary keyed on its commit, so devtool is built once + # per devtool change and skipped entirely on every other run. On a miss, + # rust-cache (above) still has its dependency tree, so the rebuild is + # just devtool's own crate. + - name: Resolve devtool commit + run: echo "DEVTOOL_SHA=$(git -C zcash-devtool rev-parse HEAD)" >> "$GITHUB_ENV" + + - name: Cache the funder binary + id: devtool-bin + uses: actions/cache@v4 + with: + path: zcash-devtool/target/release/zcash-devtool + key: devtool-bin-${{ runner.os }}-${{ env.DEVTOOL_SHA }} + + - name: Build zcash-devtool (funder) + if: steps.devtool-bin.outputs.cache-hit != 'true' + run: cargo build --release --manifest-path zcash-devtool/Cargo.toml --bin zcash-devtool + + # Pick the zebra image. The matrix names it outright on the weekly + # schedule (pinned + latest legs); otherwise ("event") it comes from the + # trigger: a manual dispatch's input, or the pinned tag for PR/push runs. + - name: Select zebra image tag + run: | + case "${{ matrix.image }}" in + pinned) tag="$ZEBRAD_IMAGE_PINNED" ;; + latest) tag="latest" ;; + *) + case "${{ github.event_name }}" in + workflow_dispatch) tag="${{ inputs.zebra-image }}"; tag="${tag:-$ZEBRAD_IMAGE_PINNED}" ;; + *) tag="$ZEBRAD_IMAGE_PINNED" ;; + esac + ;; + esac + echo "Testing against zfnd/zebra:$tag" + echo "ZEBRAD_IMAGE_TAG=$tag" >> "$GITHUB_ENV" + + # Extract zebrad + lightwalletd from their official Docker images. The + # harness drives zebrad over JSON-RPC only (mining via the Regtest-only + # `generate` RPC), so any zebrad release works. The harness reads + # ZEBRAD_BIN/LIGHTWALLETD_BIN. + - name: Provision zebrad + lightwalletd 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 "zfnd/zebra:$ZEBRAD_IMAGE_TAG" zebrad + extract electriccoinco/lightwalletd:v0.4.19 lightwalletd + echo "ZEBRAD_BIN=$RUNNER_TEMP/bin/zebrad" >> "$GITHUB_ENV" + echo "LIGHTWALLETD_BIN=$RUNNER_TEMP/bin/lightwalletd" >> "$GITHUB_ENV" + + # The harness is its own workspace, so the root ci.yml's clippy never + # sees it; lint it here where it is already being compiled. + - name: Clippy (harness) + run: cargo clippy --all-targets --locked --manifest-path regtest-harness/Cargo.toml -- -D warnings + + - name: Run regtest end-to-end harness + env: + ZKV_BIN: ${{ github.workspace }}/target/release/zkv + DEVTOOL_BIN: ${{ github.workspace }}/zcash-devtool/target/release/zcash-devtool + # Capture zebrad's logs so a startup failure is diagnosable from the + # CI run. + ZEBRAD_STDERR: ${{ runner.temp }}/zebrad.log + # ZEBRAD_BIN / LIGHTWALLETD_BIN are exported by the provisioning + # step above. + # --locked: build against the committed lock. Single-threaded: two + # zebrad+lightwalletd stacks plus Orchard proving would contend badly + # on a 2-core runner. --no-fail-fast: run every test binary even if an + # earlier one fails. + run: cargo test --locked --manifest-path regtest-harness/Cargo.toml ${{ matrix.tests }} --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 2c643ae..3a7e278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -89,9 +89,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -141,7 +141,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -152,20 +152,20 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -178,9 +178,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-broadcast" @@ -275,7 +275,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -310,7 +310,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -336,6 +336,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -344,9 +353,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -444,6 +453,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -557,18 +572,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -662,9 +677,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -673,9 +688,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -693,9 +708,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -711,9 +726,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" dependencies = [ "serde", ] @@ -724,7 +739,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -745,9 +760,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" dependencies = [ "serde_core", ] @@ -796,9 +811,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "shlex", @@ -851,7 +866,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -861,7 +887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -869,9 +895,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", @@ -921,7 +947,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -964,6 +990,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-crc32-nostd" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808ac43170e95b11dd23d78aa9eaac5bea45776a602955552c4e833f3f0f823d" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1011,7 +1043,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation", "core-graphics-types", "foreign-types", @@ -1024,7 +1056,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation", "libc", ] @@ -1044,6 +1076,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1053,20 +1094,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1074,18 +1121,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -1132,7 +1179,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1158,7 +1205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "fiat-crypto", "rustc_version", @@ -1174,7 +1221,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1207,7 +1254,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1218,14 +1265,14 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", @@ -1242,6 +1289,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive-getters" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74ef43543e701c01ad77d3a5922755c6a1d71b22d942cb8042be4994b380caff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1260,7 +1318,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1303,7 +1361,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1312,7 +1370,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -1320,13 +1378,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1349,7 +1407,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1429,15 +1487,15 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "embed-resource" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", @@ -1489,14 +1547,13 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "blake2b_simd", "corez", @@ -1526,7 +1583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1553,8 +1610,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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "blake2b_simd", ] @@ -1688,6 +1744,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 = "fnv" version = "1.0.7" @@ -1724,7 +1792,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1756,6 +1824,41 @@ dependencies = [ "num-traits", ] +[[package]] +name = "frost-core" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81ef2787af391c7e8bedc037a3b9ea03dde803fbd93e778e6bb369547800e5cd" +dependencies = [ + "byteorder", + "const-crc32-nostd", + "derive-getters", + "document-features", + "hex", + "itertools", + "postcard", + "rand_core 0.6.4", + "serde", + "serdect", + "thiserror 2.0.18", + "visibility", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "frost-rerandomized" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4c5cedd2426728adef2c0b1720f57676354c473836d1ccc50d0f0d1c91942b" +dependencies = [ + "derive-getters", + "document-features", + "frost-core", + "hex", + "rand_core 0.6.4", +] + [[package]] name = "fs4" version = "1.1.0" @@ -1841,7 +1944,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2002,36 +2105,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] name = "getset" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ - "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2072,7 +2173,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -2096,11 +2197,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" dependencies = [ "heck 0.4.1", - "proc-macro-crate 2.0.0", + "proc-macro-crate 2.0.2", "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2191,14 +2292,14 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -2268,6 +2369,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2298,6 +2408,20 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2370,9 +2494,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2424,9 +2548,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2545,7 +2669,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", "unic-langid", ] @@ -2559,7 +2683,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2574,7 +2698,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -2678,12 +2802,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2714,8 +2832,7 @@ dependencies = [ [[package]] name = "incrementalmerkletree" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30821f91f0fa8660edca547918dc59812893b497d07c1144f326f07fdd94aba9" +source = "git+https://github.com/zecrocks/incrementalmerkletree?rev=6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d#6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" dependencies = [ "either", ] @@ -2898,7 +3015,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2926,18 +3043,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2983,7 +3099,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "serde", "unicode-segmentation", ] @@ -2994,7 +3110,7 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3006,12 +3122,6 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libappindicator" version = "0.9.0" @@ -3069,9 +3179,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -3116,9 +3226,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -3170,9 +3280,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memoffset" @@ -3213,9 +3323,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -3224,9 +3334,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", @@ -3240,7 +3350,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3249,13 +3359,22 @@ 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 = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3301,14 +3420,14 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -3368,7 +3487,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3387,7 +3506,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-core-foundation", @@ -3400,7 +3519,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-foundation", ] @@ -3421,7 +3540,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", ] @@ -3432,7 +3551,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -3465,7 +3584,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3492,7 +3611,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -3505,7 +3624,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", ] @@ -3516,7 +3635,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -3528,7 +3647,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-cloud-kit", @@ -3559,7 +3678,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "objc2", "objc2-app-kit", @@ -3587,14 +3706,13 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.5" +version = "5.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" dependencies = [ "dunce", "is-wsl", "libc", - "pathdiff", ] [[package]] @@ -3611,9 +3729,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[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", @@ -3744,12 +3862,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - [[package]] name = "pbkdf2" version = "0.12.2" @@ -3764,8 +3876,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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "blake2b_simd", "bls12_381", @@ -3858,7 +3969,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3887,7 +3998,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3945,7 +4056,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -3972,7 +4083,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3986,6 +4097,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", + "heapless", "serde", ] @@ -4026,7 +4138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4041,11 +4153,12 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "2.0.0" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" dependencies = [ - "toml_edit 0.20.7", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", ] [[package]] @@ -4100,7 +4213,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4114,9 +4227,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -4124,11 +4237,11 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools", "log", "multimap", @@ -4137,28 +4250,28 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.117", + "syn 2.0.118", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] @@ -4180,16 +4293,16 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "rustls", "socket2", "thiserror 2.0.18", @@ -4200,16 +4313,17 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", @@ -4221,23 +4335,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -4267,18 +4381,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -4291,16 +4406,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -4312,12 +4417,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" @@ -4329,6 +4431,15 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4357,19 +4468,20 @@ dependencies = [ [[package]] name = "reddsa" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78a5191930e84973293aa5f532b513404460cd2216c1cfb76d08748c15b40b02" +checksum = "4784b85c8bfd17b36b86e664e6e504ecdb586001086ee23749e4a633bbb84832" dependencies = [ "blake2b_simd", "byteorder", + "frost-rerandomized", "group", "hex", "jubjub", "pasta_curves", "rand_core 0.6.4", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", "zeroize", ] @@ -4391,7 +4503,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -4422,14 +4534,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -4450,9 +4562,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -4593,7 +4705,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4623,7 +4735,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.117", + "syn 2.0.118", "walkdir", ] @@ -4645,9 +4757,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -4664,18 +4776,18 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "log", "once_cell", @@ -4700,9 +4812,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -4726,7 +4838,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4866,7 +4978,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4953,7 +5065,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation", "core-foundation-sys", "libc", @@ -4976,7 +5088,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser", "derive_more", "log", @@ -4984,7 +5096,7 @@ dependencies = [ "phf", "phf_codegen", "precomputed-hash", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "servo_arc", "smallvec", ] @@ -5053,7 +5165,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5064,14 +5176,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -5099,7 +5211,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5160,7 +5272,17 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", ] [[package]] @@ -5182,7 +5304,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5201,7 +5323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -5212,7 +5334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -5223,7 +5345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "540c0893cce56cdbcfebcec191ec8e0f470dd1889b6e7a0b503e310a94a168f5" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.11.0-pre.9", ] @@ -5239,10 +5361,9 @@ 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/zecrocks/incrementalmerkletree?rev=6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d#6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "either", "incrementalmerkletree", "tracing", @@ -5250,9 +5371,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -5311,18 +5432,18 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5378,6 +5499,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "stable_deref_trait" @@ -5450,9 +5574,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -5476,7 +5600,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5488,7 +5612,7 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml 0.8.23", + "toml 0.8.2", "version-compare", ] @@ -5498,7 +5622,7 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "core-foundation", "core-graphics", @@ -5527,7 +5651,7 @@ dependencies = [ "unicode-segmentation", "url", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", "x11-dl", ] @@ -5540,7 +5664,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5557,9 +5681,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.2" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -5608,9 +5732,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -5629,9 +5753,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -5645,7 +5769,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "syn 2.0.117", + "syn 2.0.118", "tauri-utils", "thiserror 2.0.18", "time", @@ -5656,23 +5780,23 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -5750,9 +5874,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.2" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", @@ -5775,9 +5899,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.2" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", @@ -5801,9 +5925,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", @@ -5855,20 +5979,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "tendril" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ "new_debug_unreachable", - "utf-8", ] [[package]] @@ -5878,7 +6001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5907,7 +6030,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5918,7 +6041,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6011,7 +6134,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6026,9 +6149,9 @@ dependencies = [ [[package]] name = "tokio-socks" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" dependencies = [ "either", "futures-util", @@ -6071,14 +6194,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", ] [[package]] @@ -6113,9 +6236,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" dependencies = [ "serde", ] @@ -6145,33 +6268,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.14.0", - "toml_datetime 0.6.11", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.11", + "toml_datetime 0.6.3", "winnow 0.5.40", ] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", + "toml_datetime 0.6.3", + "winnow 0.5.40", ] [[package]] @@ -6195,12 +6306,6 @@ dependencies = [ "winnow 1.0.3", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -6248,7 +6353,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6273,7 +6378,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn 2.0.117", + "syn 2.0.118", "tempfile", "tonic-build", ] @@ -6303,7 +6408,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -6346,7 +6451,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6390,9 +6495,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.23.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", "dirs", @@ -6407,7 +6512,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6422,7 +6527,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" dependencies = [ - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", ] [[package]] @@ -6433,9 +6538,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -6445,7 +6550,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6537,15 +6642,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "universal-hash" @@ -6588,12 +6687,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -6608,11 +6701,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -6650,7 +6743,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6750,27 +6843,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -6781,9 +6865,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -6791,9 +6875,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6801,48 +6885,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -6856,23 +6918,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -6890,9 +6940,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ "phf", "phf_codegen", @@ -6946,18 +6996,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -6971,7 +7021,7 @@ dependencies = [ "webview2-com-macros", "webview2-com-sys", "windows", - "windows-core 0.61.2", + "windows-core", "windows-implement", "windows-interface", ] @@ -6984,7 +7034,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6995,14 +7045,14 @@ checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", "windows", - "windows-core 0.61.2", + "windows-core", ] [[package]] name = "which" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" dependencies = [ "libc", ] @@ -7029,7 +7079,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7060,7 +7110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -7072,7 +7122,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -7084,21 +7134,8 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-result", + "windows-strings", ] [[package]] @@ -7107,7 +7144,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", "windows-threading", ] @@ -7120,7 +7157,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7131,7 +7168,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7152,7 +7189,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", ] @@ -7165,15 +7202,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -7183,15 +7211,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-sys" version = "0.45.0" @@ -7455,9 +7474,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" @@ -7478,100 +7494,12 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -7617,7 +7545,7 @@ dependencies = [ "webkit2gtk-sys", "webview2-com", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", "x11-dl", ] @@ -7672,9 +7600,9 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7689,7 +7617,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -7737,7 +7665,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zbus_names", "zvariant", "zvariant_utils", @@ -7756,9 +7684,8 @@ dependencies = [ [[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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "bech32 0.11.1", "bs58", @@ -7771,8 +7698,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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "base64 0.22.1", "bech32 0.11.1", @@ -7780,8 +7706,8 @@ dependencies = [ "bls12_381", "bs58", "byteorder", - "crossbeam-channel", "document-features", + "flume", "getset", "group", "hex", @@ -7824,12 +7750,11 @@ 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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "bip32", - "bitflags 2.11.1", + "bitflags 2.13.0", "bs58", "byteorder", "document-features", @@ -7872,8 +7797,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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "corez", "hex", @@ -7882,9 +7806,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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "bech32 0.11.1", "bip32", @@ -7916,7 +7839,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77efec759c3798b6e4d829fcc762070d9b229b0f13338c40bf993b7b609c2272" dependencies = [ - "chacha20", + "chacha20 0.9.1", "chacha20poly1305", "cipher", "rand_core 0.6.4", @@ -7925,9 +7848,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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "blake2b_simd", "block-buffer 0.11.0-rc.3", @@ -7956,9 +7878,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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "bellman", "blake2b_simd", @@ -7979,9 +7900,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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "corez", "document-features", @@ -7992,12 +7912,12 @@ dependencies = [ [[package]] name = "zcash_script" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "774d808ab619b0f1887d7b90cd815c356101698d16aa681f3d2d9dea063de475" +checksum = "2f872800287d118be71bdf6fe8c869c6a6ff6fb0a5762f68fb2af54c97edf0f2" dependencies = [ "bip32", - "bitflags 2.11.1", + "bitflags 2.13.0", "bounded-vec", "hex", "ripemd 0.1.3", @@ -8018,9 +7938,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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "bip32", "bs58", @@ -8043,22 +7962,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8078,28 +7997,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8133,7 +8052,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -8152,8 +8071,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=71da9dddc194f8adf003b14c7957d03a4836c3cd#71da9dddc194f8adf003b14c7957d03a4836c3cd" dependencies = [ "base64 0.22.1", "nom", @@ -8164,7 +8082,7 @@ dependencies = [ [[package]] name = "zkv" -version = "0.0.1-rc3" +version = "0.0.1-rc4" dependencies = [ "age", "anyhow", @@ -8201,7 +8119,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-socks", - "toml 0.8.23", + "toml 0.8.2", "tonic", "tower", "tracing", @@ -8248,7 +8166,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "toml 0.8.23", + "toml 0.8.2", "tracing", "tracing-subscriber", "zkv", @@ -8292,7 +8210,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zvariant_utils", ] @@ -8305,6 +8223,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn 2.0.118", "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index 0818784..582375a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,11 +31,17 @@ strip = true [workspace] resolver = "2" members = ["crates/zkv", "crates/zkv-oracle", "crates/zkv-version-oracle"] +# Directories that may exist inside the repo checkout but are NOT members: +# the regtest harness is deliberately its own workspace (own lockfile; see +# regtest-harness/README.md), and the Regtest E2E CI workflow checks out +# zcash-devtool (the funding wallet) into the repo root, where cargo's +# directory traversal would otherwise try to claim it for this workspace. +exclude = ["regtest-harness", "zcash-devtool"] [workspace.dependencies] # Internal crate (path resolved relative to the workspace root, so the faucet # and these root tests reference it identically). -zkv = { path = "crates/zkv", version = "0.0.1-rc3" } +zkv = { path = "crates/zkv", version = "0.0.1-rc4" } # Shared third-party deps. Versions live here so the member crates (and the # workspace-root integration tests) can't drift; per-crate feature sets and @@ -54,13 +60,73 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "json"] } # Zcash ecosystem: version-locked as a set. -transparent = { package = "zcash_transparent", version = "0.8" } -zcash_address = "0.12" -zcash_note_encryption = "0.4" +# +# Ironwood (NU6.3 / V6 transaction) support. The consensus-layer crates are +# bumped to their librustzcash Ironwood `-pre` releases; the higher-level wallet +# crates (zcash_client_backend, zcash_client_sqlite) keep their crates.io version +# numbers but must be rebuilt against the `-pre` lower crates, so the whole set is +# repointed at the zecrocks librustzcash `ironwood-scan-model` branch via +# [patch.crates-io] below (mirroring zecrocks/zcash-devtool@ironwood-valar). +transparent = { package = "zcash_transparent", version = "0.9.0-pre.0" } +zcash_address = "0.13.0-pre.0" +zcash_note_encryption = "0.4.1" zcash_client_backend = { version = "0.23", features = ["lightwalletd-tonic-tls-webpki-roots", "orchard", "pczt"] } zcash_client_sqlite = { version = "0.21", features = ["unstable", "orchard", "serde"] } -zcash_keys = { version = "0.14", features = ["orchard", "unstable"] } -zcash_primitives = "0.28" -zcash_protocol = { version = "0.9", features = ["local-consensus"] } +zcash_keys = { version = "0.15.0-pre.0", features = ["orchard", "unstable"] } +zcash_primitives = "0.29.0-pre.0" +zcash_protocol = { version = "0.10.0-pre.0", features = ["local-consensus"] } zip32 = "0.2" zip321 = "0.8" + +# Ironwood (NU6.3 / V6 transaction) support: repoint every librustzcash crate we +# depend on (directly or transitively) to the zecrocks librustzcash fork's +# `ironwood-scan-model` branch. The higher-level crates (zcash_client_backend, +# zcash_client_sqlite, pczt, zip321) keep their crates.io version numbers but +# depend on the `-pre` lower crates, so the crates.io releases can't be used +# directly: a git source is required. `rev 409f3490` is the current head of that +# branch, which extends the upstream ironwood line (Ironwood *send* + +# *receive* wiring) with the full **Ironwood scan model**: selecting and +# spending received Ironwood notes, counting them in the account balance, the +# Orchard-turnstile enforcement, V6-PCZT construction for Ironwood targets, and +# the `ironwood_tree_retained_checkpoints` migration table (so the wallet no +# longer needs zkv to create it out of band; see the note in `data.rs`). +# zkv both writes and reads memos in the Orchard/Ironwood pool, so this fuller +# wallet support is what lets a post-NU6.3 database spend its own notes and see +# its own V6 writes. Patches must live in the workspace root to take effect. +# Follows zecrocks/zcash-devtool@ironwood-valar. +# +# The branch depends on `orchard 0.15.0-pre.2` (published on crates.io, so it is +# NOT patched here; our direct dependency in crates/zkv is bumped to match) and +# on an unreleased `shardtree`/`incrementalmerkletree` (explicit checkpoint +# retention, zcash/incrementalmerkletree `decefc46`). A `[patch]` only takes +# effect from the workspace root, so we must mirror that incrementalmerkletree +# patch here or the branch's crates won't resolve against a compatible shardtree. +[patch.crates-io] +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 scan-model branch needs unreleased checkpoint-retention APIs; mirror its +# incrementalmerkletree patch. Pinned to the zecrocks fork rev 6e083a4, which is +# upstream decefc46 plus one shardtree fix: `clear_flags` no longer panics +# ("Tree state inconsistent with checkpoints.") when checkpoint pruning collapses +# several densely-packed checkpoint positions into a single pruned leaf. This is +# reachable on a sparsely-used Ironwood tree that is still checkpointed every +# block by cross-pool synchronization (100+ checkpoints on one unmarked shard), +# and without it a post-NU6.3 testnet Orchard/Ironwood database panics on sync +# once its Ironwood tree passes PRUNING_DEPTH checkpoints. Drop back to an +# upstream rev once the fix lands there. +shardtree = { git = "https://github.com/zecrocks/incrementalmerkletree", rev = "6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" } +incrementalmerkletree = { git = "https://github.com/zecrocks/incrementalmerkletree", rev = "6e083a49ef44385cad6b15d4ae1fa9e5e3f6b25d" } +# incrementalmerkletree-testing is a dev-only dep the branch patches for its own +# tests; it is not in zkv's crate graph, so patching it here is unused. Omitted. diff --git a/crates/zkv-oracle/Cargo.toml b/crates/zkv-oracle/Cargo.toml index 0aa6370..9d01b8b 100644 --- a/crates/zkv-oracle/Cargo.toml +++ b/crates/zkv-oracle/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" [dependencies] # The facade needs neither the CLI nor GUI feature trees; keep the build lean # (no clap/qrcode/axum-from-gui/license-bundle). -zkv = { path = "../zkv", version = "0.0.1-rc3", default-features = false, features = ["transparent-inputs"] } +zkv = { path = "../zkv", version = "0.0.1-rc4", default-features = false, features = ["transparent-inputs"] } anyhow = "1" serde = { version = "1.0", features = ["derive"] } diff --git a/crates/zkv-oracle/src/main.rs b/crates/zkv-oracle/src/main.rs index 69822eb..b8e7640 100644 --- a/crates/zkv-oracle/src/main.rs +++ b/crates/zkv-oracle/src/main.rs @@ -36,7 +36,7 @@ use serde_json::{json, Value}; use zkv::{ config::Role, - data::{set_data_dir_override, Network}, + data::set_data_dir_override, db::{Database, ZkvError}, protocol::{network_from_type, parse_zkv_addr}, remote::{ConnectionArgs, ConnectionMode, Servers}, @@ -512,7 +512,7 @@ async fn restore_from_address(cfg: &Config, conn: ConnectionArgs) -> anyhow::Res })?; let parsed = parse_zkv_addr(addr).context("parsing ZKV_ADDRESS")?; - let network = Network::from(network_from_type(parsed.network).context("ZKV_ADDRESS network")?); + let network = network_from_type(parsed.network).context("ZKV_ADDRESS network")?; // The birthday lives inside the address; a ZKV_BIRTHDAY override is honoured // for unusual cases (e.g. forcing an earlier rescan). let birthday = cfg.birthday.or(Some(parsed.birthday)); diff --git a/crates/zkv-version-oracle/Cargo.toml b/crates/zkv-version-oracle/Cargo.toml index ec5e768..7ab7fdc 100644 --- a/crates/zkv-version-oracle/Cargo.toml +++ b/crates/zkv-version-oracle/Cargo.toml @@ -12,7 +12,7 @@ name = "zkv-version-oracle" path = "src/main.rs" [dependencies] -zkv = { path = "../zkv", version = "0.0.1-rc3" } +zkv = { path = "../zkv", version = "0.0.1-rc4" } anyhow = "1" clap = { version = "4.5", features = ["derive", "string"] } diff --git a/crates/zkv/Cargo.toml b/crates/zkv/Cargo.toml index e0f63fc..f0c363d 100644 --- a/crates/zkv/Cargo.toml +++ b/crates/zkv/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zkv" -version = "0.0.1-rc3" +version = "0.0.1-rc4" edition = "2021" rust-version = "1.88" license = "MIT OR Apache-2.0" @@ -52,7 +52,10 @@ ripemd = "0.1" nonempty = { version = "0.11", default-features = false } # Zcash -orchard = { version = "0.14", default-features = false } +# Pinned to the exact pre-release the zecrocks ironwood-scan-model branch depends +# on (a bare "0.15.0-pre.2" would also admit later `-pre.N`). Not patched: it is +# published on crates.io, so it resolves the same as the branch's dependency. +orchard = { version = "=0.15.0-pre.2", default-features = false } pczt = "0.7" sapling = { package = "sapling-crypto", version = "0.7" } transparent = { workspace = true } @@ -64,7 +67,7 @@ zcash_keys = { workspace = true } # tree transitively via sapling-crypto / orchard / zcash_client_backend). zcash_note_encryption = { workspace = true } zcash_primitives = { workspace = true } -zcash_proofs = { version = "0.28", features = ["bundled-prover"] } +zcash_proofs = { version = "0.29.0-pre.0", features = ["bundled-prover"] } zcash_protocol = { workspace = true } zcash_script = "0.4" zip32 = { workspace = true } diff --git a/crates/zkv/src/commands/address.rs b/crates/zkv/src/commands/address.rs index 1dc5d02..78a1c13 100644 --- a/crates/zkv/src/commands/address.rs +++ b/crates/zkv/src/commands/address.rs @@ -69,7 +69,7 @@ impl Command { } else { println!("{addr}"); // Hint goes to stderr so it never pollutes the piped address value. - let ticker = crate::data::Network::from(cfg.network).ticker(); + let ticker = cfg.network.ticker(); ui::hint(format!( "Tip: run `zkv address --funding` for the address to send {ticker} to." )); diff --git a/crates/zkv/src/commands/balance.rs b/crates/zkv/src/commands/balance.rs index 4618674..e2779da 100644 --- a/crates/zkv/src/commands/balance.rs +++ b/crates/zkv/src/commands/balance.rs @@ -45,7 +45,7 @@ impl Command { .map(|b| u64::from(b.total())) .sum(); - let network = crate::data::Network::from(cfg.network); + let network = cfg.network; println!("{}", format_zec(total_zat as i64, network).trim_start()); if watch_only { ui::hint("(watch-only, cannot send)"); diff --git a/crates/zkv/src/commands/get.rs b/crates/zkv/src/commands/get.rs index ff17f12..9bb8b0b 100644 --- a/crates/zkv/src/commands/get.rs +++ b/crates/zkv/src/commands/get.rs @@ -6,7 +6,7 @@ use serde::Serialize; use crate::{ commands::connection_args::ConnectionCliArgs, config::{pool_label, WalletConfig}, - data::{self, resolve_db}, + data::resolve_db, internal::{ pending, protocol::{InitState, KeyState, PendingOp}, @@ -63,7 +63,7 @@ impl Command { eprintln!( "# Database: {name} ({}, {}, {})", format!("{:?}", cfg.role).to_lowercase(), - data::Network::from(cfg.network).name(), + cfg.network.name(), pool_label(cfg.pool), ); } diff --git a/crates/zkv/src/commands/init.rs b/crates/zkv/src/commands/init.rs index 06ad67e..4187f83 100644 --- a/crates/zkv/src/commands/init.rs +++ b/crates/zkv/src/commands/init.rs @@ -6,8 +6,7 @@ use bip0039::{Count, English, Mnemonic}; use clap::Args; use secrecy::{SecretVec, Zeroize}; use zcash_client_backend::data_api::{wallet::ConfirmationsPolicy, WalletRead, WalletWrite}; -use zcash_protocol::consensus; -use zcash_protocol::ShieldedProtocol; +use zcash_protocol::ShieldedPool; use crate::{ commands::connection_args::ConnectionCliArgs, @@ -44,14 +43,19 @@ pub(crate) struct Command { /// Database name. Defaults to "default". pub(crate) name: Option, - /// Network: "mainnet" (default) or "testnet". + /// Network: "mainnet" (default), "testnet", or "regtest". #[arg(long, default_value = "mainnet", value_parser = Network::parse)] pub(crate) network: Network, - /// Shielded pool for this database: "orchard" (default) or "sapling". + /// Shielded pool for this database: "ironwood", "orchard", or "sapling". /// Fixed at creation; every memo is read from and written to this pool. - #[arg(long, default_value = "orchard", value_parser = parse_pool)] - pub(crate) pool: ShieldedProtocol, + /// Defaults to the network's pool when omitted: Ironwood on testnet (the + /// NU6.3 Orchard pool), Orchard on mainnet (Ironwood is rejected on mainnet + /// until NU6.3 activates there). Ironwood and Orchard share the Orchard + /// receiver, so the choice only affects the label and the transaction + /// version a send builds. + #[arg(long, value_parser = parse_pool)] + pub(crate) pool: Option, /// Skip the type-it-back confirmation prompt AND the funding/INIT poll /// loop. Prints the funding instructions (no QR), the exact INIT memo @@ -101,7 +105,7 @@ impl Command { .or(crate::data::current_db()?) .unwrap_or_else(|| DEFAULT_DB.to_owned()), }; - let params: consensus::Network = self.network.into(); + let params = self.network; let dir = db_dir(&name)?; if dir.join("keys.toml").exists() { @@ -113,6 +117,11 @@ impl Command { return self.resume(&name).await; } + // Resolve the pool now that the network is known: default per network + // (Ironwood on testnet, Orchard on mainnet) and reject Ironwood on + // mainnet, before minting a seed or touching the chain. + let pool = crate::config::resolve_pool_for_network(self.pool, params)?; + // Mnemonic ceremony first; user sees their phrase even if the network is down. let mnemonic = Mnemonic::generate(Count::Words24); if !self.non_interactive { @@ -125,9 +134,9 @@ impl Command { // no history before now. Refuses a stale/unreachable tip so the birthday // is never anchored to a stale view of the chain. let mut client = connection.connect(params).await?; - let birthday = crate::internal::sync::near_tip_birthday(&mut client).await?; + let birthday = crate::internal::sync::near_tip_birthday(&mut client, params).await?; - WalletConfig::init_admin(&name, &mnemonic, birthday.height(), params, self.pool)?; + WalletConfig::init_admin(&name, &mnemonic, birthday.height(), params, pool)?; let seed = { let mut s = mnemonic.to_seed(""); @@ -146,7 +155,7 @@ impl Command { let ufvk = zcash_client_backend::data_api::Account::ufvk(&account) .ok_or_else(|| anyhow!("no UFVK"))?; zkv_verifying_pubkey(ufvk)?; - let zkv_addr = encode_zkv_addr(ufvk, ¶ms, self.pool, u32::from(birthday.height()))?; + let zkv_addr = encode_zkv_addr(ufvk, ¶ms, pool, u32::from(birthday.height()))?; drop(db_data); // Build the signed INIT memo + recipient UA up-front so we can display @@ -223,7 +232,7 @@ impl Command { so it cannot broadcast INIT" ); } - let network = Network::from(cfg.network); + let network = cfg.network; let connection = self.connection.into_inner(); eprintln!("Database {name:?} already exists; checking whether it needs initialization…"); diff --git a/crates/zkv/src/commands/inspect.rs b/crates/zkv/src/commands/inspect.rs index 4ca74a8..171eb4e 100644 --- a/crates/zkv/src/commands/inspect.rs +++ b/crates/zkv/src/commands/inspect.rs @@ -1,6 +1,6 @@ use clap::{Args, ValueEnum}; use serde::Serialize; -use zcash_protocol::{consensus::NetworkType, ShieldedProtocol}; +use zcash_protocol::{consensus::NetworkType, ShieldedPool}; use crate::internal::protocol::{ network_from_type, parse_zkv_addr, pubkey_bech32, receiver_domain, ua_request_for_pool, @@ -73,7 +73,6 @@ fn describe(zkv_addr: &str) -> anyhow::Result { /// The funding address: a shielded-only unified address (the database's single /// pool, no transparent receiver) you send ZEC to so the wallet can pay write /// fees. Derived straight from the viewing key, so no synced wallet is needed. -/// Only the networks the wallet stack supports get one (not regtest). fn funding_address(parsed: &ParsedZkvAddr) -> Option { let net = network_from_type(parsed.network).ok()?; parsed @@ -91,10 +90,11 @@ fn network_label(net: NetworkType) -> &'static str { } } -fn pool_label(pool: ShieldedProtocol) -> &'static str { +fn pool_label(pool: ShieldedPool) -> &'static str { match pool { - ShieldedProtocol::Orchard => "orchard", - ShieldedProtocol::Sapling => "sapling", + ShieldedPool::Orchard => "orchard", + ShieldedPool::Sapling => "sapling", + ShieldedPool::Ironwood => "ironwood", } } @@ -146,7 +146,7 @@ mod tests { use crate::internal::protocol::encode_zkv_addr; - fn sample_addr(net: consensus::Network, pool: ShieldedProtocol, birthday: u32) -> String { + fn sample_addr(net: P, pool: ShieldedPool, birthday: u32) -> String { let ufvk = UnifiedSpendingKey::from_seed(&net, &[0x42; 32], zip32::AccountId::ZERO) .expect("derive USK") .to_unified_full_viewing_key(); @@ -157,14 +157,16 @@ mod tests { fn describes_a_testnet_orchard_address() { let addr = sample_addr( consensus::Network::TestNetwork, - ShieldedProtocol::Orchard, + ShieldedPool::Orchard, 1_234_567, ); let info = describe(&addr).expect("describe"); assert_eq!(info.zkv_address, addr); assert_eq!(info.network, "testnet"); - assert_eq!(info.pool, "orchard"); + // An Orchard-receiver address resolves to Ironwood (shared receiver): + // this is the "old Orchard wallet imports as Ironwood" behavior. + assert_eq!(info.pool, "ironwood"); assert_eq!(info.birthday, 1_234_567); // The fields derived from the viewing key are populated and well-formed. assert!( @@ -183,7 +185,7 @@ mod tests { fn pool_is_inferred_from_the_published_key() { let addr = sample_addr( consensus::Network::MainNetwork, - ShieldedProtocol::Sapling, + ShieldedPool::Sapling, 900_000, ); let info = describe(&addr).expect("describe"); @@ -193,6 +195,33 @@ mod tests { assert!(info.receiver.starts_with("main:"), "{}", info.receiver); } + #[test] + fn describes_a_regtest_address() { + // The regtest HRP family (`zkvregtest1...`) round-trips offline just + // like the public networks; the regtest e2e harness relies on this. + let addr = sample_addr(crate::data::Network::Regtest, ShieldedPool::Orchard, 42); + assert!(addr.starts_with("zkvregtest1"), "{addr}"); + let info = describe(&addr).expect("describe"); + assert_eq!(info.network, "regtest"); + // Ironwood is available on regtest (a testnet-flavored local chain), so + // an Orchard-receiver address resolves to Ironwood there. + assert_eq!(info.pool, "ironwood"); + assert_eq!(info.birthday, 42); + assert!( + info.signing_key.starts_with("zkvid1"), + "{}", + info.signing_key + ); + assert!( + info.view_key.starts_with("uviewregtest1"), + "{}", + info.view_key + ); + assert!(info.receiver.starts_with("regtest:"), "{}", info.receiver); + let funding = info.funding_address.expect("funding address"); + assert!(funding.starts_with("uregtest1"), "{funding}"); + } + #[test] fn rejects_a_non_zkv_token() { assert!(describe("not-an-address").is_err()); diff --git a/crates/zkv/src/commands/list.rs b/crates/zkv/src/commands/list.rs index 0fadb4a..e7d01e8 100644 --- a/crates/zkv/src/commands/list.rs +++ b/crates/zkv/src/commands/list.rs @@ -28,61 +28,82 @@ impl Command { return Ok(()); } let current = data::current_db()?; - for name in dbs { - let marker = if current.as_deref() == Some(&name) { - "*" - } else { - " " - }; - let (role, network, pool, creator) = match WalletConfig::read(&name) { - Ok(cfg) => ( - format!("{:?}", cfg.role).to_lowercase(), - data::Network::from(cfg.network).name().to_owned(), - pool_label(cfg.pool).to_owned(), - // Admin = we hold the seed whose UFVK roots this address, - // i.e. we hold the root signing key that bootstraps the db - // via INIT. Watch-only dbs never do. - cfg.role == Role::Admin, - ), - Err(_) => ("?".to_owned(), "?".to_owned(), "?".to_owned(), false), - }; - let mut tags = Vec::new(); - if creator { - tags.push("creator".to_owned()); - } - tags.push(role); - tags.push(network); - tags.push(pool); - // Init state is read from local cache only (no sync). Anything that - // isn't a clean `Initialized` is surfaced so uninitialized (or - // still-confirming) databases stand out; an unreadable state is - // skipped silently rather than failing the whole listing. - if let Ok(state) = load_state(&name, INIT_CONFIRMATIONS, false) { - match state.init { - InitState::Initialized => {} - InitState::Initializing { done, required } => { - tags.push(format!("initializing {done}/{required}")); - } - // Only an admin database (one we created) is meaningfully - // "uninitialized": it exists locally but hasn't been INIT'd - // yet. A watch-only database we merely imported has no INIT - // of its own, and before its first sync we simply don't know - // its state, so don't mislabel it. - InitState::Uninitialized if creator => tags.push("uninitialized".to_owned()), - InitState::Uninitialized => {} - } - } - if self.balances { - if let Some(bal) = last_known_balance(&name) { - tags.push(bal); - } - } - println!("{marker} {name} ({})", tags.join(", ")); + let balances = self.balances; + + // Each database is an independent set of sqlite files (wallet DB + + // snapshot cache), and describing one is dominated by sqlite opens and a + // local state replay. Run them concurrently instead of strictly + // serially so `zkv list` doesn't slow down linearly with the number of + // databases. Output order is preserved by joining the handles in order. + let lines: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = dbs + .iter() + .map(|name| { + let current = current.as_deref(); + scope.spawn(move || describe_db(name, current, balances)) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + for line in lines { + println!("{line}"); } Ok(()) } } +/// Build one `zkv list` output line for a single database. Reads local state +/// only (no sync); an unreadable config or state degrades gracefully rather +/// than failing the whole listing. +fn describe_db(name: &str, current: Option<&str>, balances: bool) -> String { + let marker = if current == Some(name) { "*" } else { " " }; + let (role, network, pool, creator) = match WalletConfig::read(name) { + Ok(cfg) => ( + format!("{:?}", cfg.role).to_lowercase(), + cfg.network.name().to_owned(), + pool_label(cfg.pool).to_owned(), + // Admin = we hold the seed whose UFVK roots this address, i.e. we + // hold the root signing key that bootstraps the db via INIT. + // Watch-only dbs never do. + cfg.role == Role::Admin, + ), + Err(_) => ("?".to_owned(), "?".to_owned(), "?".to_owned(), false), + }; + let mut tags = Vec::new(); + if creator { + tags.push("creator".to_owned()); + } + tags.push(role); + tags.push(network); + tags.push(pool); + // Init state is read from local cache only (no sync). Anything that isn't a + // clean `Initialized` is surfaced so uninitialized (or still-confirming) + // databases stand out; an unreadable state is skipped silently rather than + // failing the whole listing. + if let Ok(state) = load_state(name, INIT_CONFIRMATIONS, false) { + match state.init { + InitState::Initialized => {} + InitState::Initializing { done, required } => { + tags.push(format!("initializing {done}/{required}")); + } + // Only an admin database (one we created) is meaningfully + // "uninitialized": it exists locally but hasn't been INIT'd yet. A + // watch-only database we merely imported has no INIT of its own, and + // before its first sync we simply don't know its state, so don't + // mislabel it. + InitState::Uninitialized if creator => tags.push("uninitialized".to_owned()), + InitState::Uninitialized => {} + } + } + if balances { + if let Some(bal) = last_known_balance(name) { + tags.push(bal); + } + } + format!("{marker} {name} ({})", tags.join(", ")) +} + /// Last-known wallet balance for a database, formatted as e.g. `1.23 TAZ`, /// or `None` for watch-only / unreadable databases (which hold no spending /// key and thus no balance). Reads the local wallet DB only; no sync, so the @@ -102,7 +123,7 @@ fn last_known_balance(name: &str) -> Option { .values() .map(|b| u64::from(b.total())) .sum(); - let network = data::Network::from(cfg.network); + let network = cfg.network; Some( format_zec(total_zat as i64, network) .trim_start() diff --git a/crates/zkv/src/commands/restore.rs b/crates/zkv/src/commands/restore.rs index e85a346..49a70ba 100644 --- a/crates/zkv/src/commands/restore.rs +++ b/crates/zkv/src/commands/restore.rs @@ -3,8 +3,7 @@ use bip0039::{English, Mnemonic}; use clap::Args; use secrecy::{SecretVec, Zeroize}; use zcash_client_backend::data_api::WalletWrite; -use zcash_protocol::consensus; -use zcash_protocol::ShieldedProtocol; +use zcash_protocol::ShieldedPool; use crate::{ commands::connection_args::ConnectionCliArgs, @@ -40,12 +39,16 @@ pub(crate) struct Command { #[arg(long)] pub(crate) birthday: Option, - /// Shielded pool of the database being restored: "orchard" (default) or + /// Shielded pool of the database being restored: "ironwood", "orchard", or /// "sapling". Must match the pool chosen at the original `zkv init`, or the /// reconstructed zkv address won't match and the database will look empty. - /// Ignored when `--address` is given (the address carries the pool). - #[arg(long, default_value = "orchard", value_parser = parse_pool)] - pub(crate) pool: ShieldedProtocol, + /// Defaults to the network's pool when omitted (Ironwood on testnet, Orchard + /// on mainnet). Ironwood and Orchard share the Orchard receiver, so an old + /// Orchard wallet restored as Ironwood on testnet derives the identical + /// address and reads the identical memos. Ignored when `--address` is given + /// (the address carries the pool). + #[arg(long, value_parser = parse_pool)] + pub(crate) pool: Option, #[command(flatten)] pub(crate) connection: ConnectionCliArgs, @@ -75,14 +78,17 @@ impl Command { None => None, }; let network: Network = match &parsed_addr { - Some(p) => network_from_type(p.network)?.into(), + Some(p) => network_from_type(p.network)?, None => self.network, }; + let params = network; + // With an address, the pool is authoritative (and already network-aware + // from parsing). Without one, resolve the flag against the network: + // default per network, and reject Ironwood on mainnet. let pool = match &parsed_addr { Some(p) => p.pool, - None => self.pool, + None => crate::config::resolve_pool_for_network(self.pool, network)?, }; - let params: consensus::Network = network.into(); let dir = db_dir(&name)?; if dir.join("keys.toml").exists() { @@ -149,7 +155,8 @@ impl Command { .or_else(|| parsed_addr.as_ref().map(|p| p.birthday)) .expect("address or birthday is required (checked above)"); let mut client = connection.connect(params).await?; - let birthday = crate::internal::sync::pinned_birthday(&mut client, birthday_height).await?; + let birthday = + crate::internal::sync::pinned_birthday(&mut client, params, birthday_height).await?; WalletConfig::init_admin(&name, &mnemonic, birthday.height(), params, pool)?; diff --git a/crates/zkv/src/commands/show.rs b/crates/zkv/src/commands/show.rs index 7523de0..5b032d6 100644 --- a/crates/zkv/src/commands/show.rs +++ b/crates/zkv/src/commands/show.rs @@ -64,11 +64,12 @@ impl Command { Role::Admin => "admin", Role::Watch => "watch", }; - let network_label = crate::data::Network::from(cfg.network); + let network_label = cfg.network; let net_str = network_label.name(); let pool_str = match cfg.pool { - zcash_protocol::ShieldedProtocol::Sapling => "sapling", - zcash_protocol::ShieldedProtocol::Orchard => "orchard", + zcash_protocol::ShieldedPool::Sapling => "sapling", + zcash_protocol::ShieldedPool::Orchard => "orchard", + zcash_protocol::ShieldedPool::Ironwood => "ironwood", }; // The funding address: a shielded-only unified address (the database's diff --git a/crates/zkv/src/commands/sync.rs b/crates/zkv/src/commands/sync.rs index 980423b..9bb8e3e 100644 --- a/crates/zkv/src/commands/sync.rs +++ b/crates/zkv/src/commands/sync.rs @@ -33,7 +33,7 @@ impl Command { // Report where we synced from: network, the server we picked, and the // transport. `pick` can't fail here (the sync above already connected // through it), but fall back to a bare line if it somehow does. - let network = crate::data::Network::from(cfg.network).name().to_owned(); + let network = cfg.network.name().to_owned(); match connection.server.pick(cfg.network) { Ok(server) => { let via = match &connection.connection { diff --git a/crates/zkv/src/commands/watch.rs b/crates/zkv/src/commands/watch.rs index 92f78be..7b49c9d 100644 --- a/crates/zkv/src/commands/watch.rs +++ b/crates/zkv/src/commands/watch.rs @@ -54,7 +54,8 @@ impl Command { // The birthday is carried by the address, so pin it verbatim (no // buffer). Refuses a stale/unreachable tip before building the db. let mut client = connection.connect(network).await?; - let birthday = crate::internal::sync::pinned_birthday(&mut client, parsed.birthday).await?; + let birthday = + crate::internal::sync::pinned_birthday(&mut client, network, parsed.birthday).await?; WalletConfig::init_watch( &name, diff --git a/crates/zkv/src/config.rs b/crates/zkv/src/config.rs index 10c5c72..c58f26e 100644 --- a/crates/zkv/src/config.rs +++ b/crates/zkv/src/config.rs @@ -30,8 +30,8 @@ use bip0039::{English, Mnemonic}; use secrecy::{ExposeSecret, SecretVec, Zeroize}; use serde::{Deserialize, Serialize}; -use zcash_protocol::consensus::{self, BlockHeight, Parameters}; -use zcash_protocol::ShieldedProtocol; +use zcash_protocol::consensus::{BlockHeight, NetworkUpgrade, Parameters}; +use zcash_protocol::ShieldedPool; use crate::{ data::{db_dir, ensure_db_dir, Network}, @@ -59,7 +59,7 @@ pub enum Role { } pub struct WalletConfig { - pub network: consensus::Network, + pub network: Network, pub role: Role, pub birthday: BlockHeight, /// For Watch databases, the original zkv address used at `zkv watch` @@ -71,38 +71,90 @@ pub struct WalletConfig { /// The single shielded pool this database lives in: every memo is read /// from, and written to, this pool. Chosen at creation and fixed /// thereafter. Absent in `keys.toml` (legacy databases) means Orchard. - pub pool: ShieldedProtocol, + pub pool: ShieldedPool, seed_ciphertext: Option, db_dir: PathBuf, } -/// Parse a `--pool` CLI value into a [`ShieldedProtocol`]. Strict: only -/// `"sapling"` and `"orchard"` are accepted (the `String` error feeds clap). -pub fn parse_pool(s: &str) -> Result { +/// Parse a `--pool` value into a [`ShieldedPool`]: `"ironwood"`, `"orchard"`, +/// or `"sapling"`. Ironwood and Orchard share the Orchard receiver and are +/// chain-identical; which one a new database uses is a per-network policy +/// (see [`default_pool_for_network`] / [`ironwood_available`]): Ironwood is the +/// testnet default, Orchard the mainnet one (NU6.3 is not yet active on +/// mainnet). The `String` error feeds clap; network validation happens at the +/// creation call site, not here. +pub fn parse_pool(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { - "sapling" => Ok(ShieldedProtocol::Sapling), - "orchard" => Ok(ShieldedProtocol::Orchard), + "sapling" => Ok(ShieldedPool::Sapling), + "orchard" => Ok(ShieldedPool::Orchard), + "ironwood" => Ok(ShieldedPool::Ironwood), other => Err(format!( - "unknown pool {other:?} (expected \"sapling\" or \"orchard\")" + "unknown pool {other:?} (expected \"ironwood\", \"orchard\", or \"sapling\")" )), } } -/// Lowercase label for a pool (`"sapling"`/`"orchard"`), as written to -/// `keys.toml` and surfaced to the GUI. -pub fn pool_label(pool: ShieldedProtocol) -> &'static str { +/// Whether the Ironwood (NU6.3) pool can back a database on `network`. Ironwood +/// is live on testnet and regtest but not yet on mainnet, so it is available on +/// every network except mainnet; on mainnet the equivalent pool is plain +/// Orchard (an Orchard wallet auto-upgrades to Ironwood once NU6.3 activates on +/// mainnet, since they share the Orchard receiver). +pub fn ironwood_available(network: Network) -> bool { + !matches!(network, Network::Main) +} + +/// The default shielded pool for a new database on `network`: Ironwood on +/// testnet/regtest (the current NU6.3 Orchard pool), Orchard on mainnet (until +/// NU6.3 activates there). +pub fn default_pool_for_network(network: Network) -> ShieldedPool { + if ironwood_available(network) { + ShieldedPool::Ironwood + } else { + ShieldedPool::Orchard + } +} + +/// Resolve the pool for a **new** database: fall back to the network default +/// when unspecified, and reject Ironwood on a network where it isn't available +/// yet (mainnet). Shared by `zkv init`/`restore` and the facade so the policy +/// lives in one place. +pub fn resolve_pool_for_network( + pool: Option, + network: Network, +) -> anyhow::Result { + let pool = pool.unwrap_or_else(|| default_pool_for_network(network)); + if pool == ShieldedPool::Ironwood && !ironwood_available(network) { + anyhow::bail!( + "the Ironwood pool is not yet available on mainnet (NU6.3 has not activated \ + there); create the database with `--pool orchard`, or use testnet" + ); + } + Ok(pool) +} + +/// Lowercase label for a pool, as written to `keys.toml` and surfaced to the +/// GUI. Ironwood and Orchard are distinct labels (Ironwood is new; legacy +/// databases stay labelled `"orchard"`) even though they share the Orchard +/// receiver and are handled identically on the chain. +pub fn pool_label(pool: ShieldedPool) -> &'static str { match pool { - ShieldedProtocol::Sapling => "sapling", - ShieldedProtocol::Orchard => "orchard", + ShieldedPool::Sapling => "sapling", + ShieldedPool::Orchard => "orchard", + ShieldedPool::Ironwood => "ironwood", } } -/// Parse a `keys.toml` pool label. Unknown or absent labels fall back to -/// Orchard (the original, pre-pool-field behavior) at the call site. -fn pool_from_label(label: Option<&str>) -> ShieldedProtocol { +/// Parse a `keys.toml` pool label. `"ironwood"` is the pool for databases +/// created since NU6.3; `"orchard"` and an absent label are the legacy Orchard +/// form (kept as-is so existing databases open unchanged, and handled +/// identically to Ironwood since they share the Orchard receiver). +fn pool_from_label(label: Option<&str>) -> ShieldedPool { match label { - Some("sapling") => ShieldedProtocol::Sapling, - _ => ShieldedProtocol::Orchard, + Some("sapling") => ShieldedPool::Sapling, + Some("ironwood") => ShieldedPool::Ironwood, + // `Some("orchard")` and `None` (pre-pool-field databases) are legacy + // Orchard, behaviourally identical to Ironwood. + _ => ShieldedPool::Orchard, } } @@ -115,8 +167,8 @@ impl WalletConfig { db_name: &str, mnemonic: &Mnemonic, birthday: BlockHeight, - network: consensus::Network, - pool: ShieldedProtocol, + network: Network, + pool: ShieldedPool, ) -> anyhow::Result<()> { let dir = ensure_db_dir(db_name)?; @@ -135,7 +187,7 @@ impl WalletConfig { &dir, ConfigEncoding { mnemonic: Some(ciphertext), - network: Some(Network::from(network).name().to_string()), + network: Some(network.name().to_string()), birthday: Some(u32::from(birthday)), role: Some("admin".to_owned()), zkv_address: None, @@ -149,16 +201,16 @@ impl WalletConfig { pub fn init_watch( db_name: &str, birthday: BlockHeight, - network: consensus::Network, + network: Network, zkv_address: &str, - pool: ShieldedProtocol, + pool: ShieldedPool, ) -> anyhow::Result<()> { let dir = ensure_db_dir(db_name)?; write_config( &dir, ConfigEncoding { mnemonic: None, - network: Some(Network::from(network).name().to_string()), + network: Some(network.name().to_string()), birthday: Some(u32::from(birthday)), role: Some("watch".to_owned()), zkv_address: Some(zkv_address.to_owned()), @@ -182,17 +234,13 @@ impl WalletConfig { let cfg: ConfigEncoding = toml::from_str(&buf)?; let network = cfg.network.map_or_else( - || Ok(consensus::Network::MainNetwork), - |n| { - Network::parse(n.trim()) - .map(consensus::Network::from) - .map_err(|_| error::Error::InvalidKeysFile) - }, + || Ok(Network::Main), + |n| Network::parse(n.trim()).map_err(|_| error::Error::InvalidKeysFile), )?; let birthday = cfg.birthday.map(BlockHeight::from).unwrap_or_else(|| { network - .activation_height(consensus::NetworkUpgrade::Sapling) + .activation_height(NetworkUpgrade::Sapling) .expect("Sapling activation height known") }); @@ -260,10 +308,15 @@ struct ConfigEncoding { /// `keys.toml` encoding for a pool. Orchard is the implied default and is /// omitted, so newly-created Orchard databases keep byte-identical config to /// pre-pool-field databases. -fn pool_encoding(pool: ShieldedProtocol) -> Option { +fn pool_encoding(pool: ShieldedPool) -> Option { match pool { - ShieldedProtocol::Orchard => None, - ShieldedProtocol::Sapling => Some(pool_label(pool).to_owned()), + // Orchard is the implied default: omit it so legacy databases keep + // byte-identical config. + ShieldedPool::Orchard => None, + ShieldedPool::Sapling => Some(pool_label(pool).to_owned()), + // Ironwood (the default for databases created since NU6.3) is written + // explicitly; absence still means legacy Orchard. + ShieldedPool::Ironwood => Some(pool_label(pool).to_owned()), } } @@ -386,35 +439,91 @@ mod tests { #[test] fn parse_pool_accepts_known_values_case_insensitively() { - assert_eq!(parse_pool("orchard"), Ok(ShieldedProtocol::Orchard)); - assert_eq!(parse_pool("sapling"), Ok(ShieldedProtocol::Sapling)); - assert_eq!(parse_pool(" Sapling "), Ok(ShieldedProtocol::Sapling)); - assert_eq!(parse_pool("ORCHARD"), Ok(ShieldedProtocol::Orchard)); + // parse_pool is literal; the Ironwood/Orchard network policy is applied + // separately (see resolve_pool_for_network). + assert_eq!(parse_pool("orchard"), Ok(ShieldedPool::Orchard)); + assert_eq!(parse_pool("ironwood"), Ok(ShieldedPool::Ironwood)); + assert_eq!(parse_pool("sapling"), Ok(ShieldedPool::Sapling)); + assert_eq!(parse_pool(" Sapling "), Ok(ShieldedPool::Sapling)); + assert_eq!(parse_pool("ORCHARD"), Ok(ShieldedPool::Orchard)); + assert_eq!(parse_pool("Ironwood"), Ok(ShieldedPool::Ironwood)); assert!(parse_pool("transparent").is_err()); assert!(parse_pool("").is_err()); } + #[test] + fn pool_network_policy() { + use Network::{Main as MainNetwork, Regtest, Test as TestNetwork}; + // Ironwood is available everywhere except mainnet (testnet + regtest). + assert!(!ironwood_available(MainNetwork)); + assert!(ironwood_available(TestNetwork)); + assert!(ironwood_available(Regtest)); + // Defaults: mainnet -> Orchard, testnet/regtest -> Ironwood. + assert_eq!(default_pool_for_network(MainNetwork), ShieldedPool::Orchard); + assert_eq!( + default_pool_for_network(TestNetwork), + ShieldedPool::Ironwood + ); + assert_eq!(default_pool_for_network(Regtest), ShieldedPool::Ironwood); + // resolve_pool_for_network: unspecified falls back to the network + // default; Ironwood on mainnet is rejected; Orchard/Sapling are fine + // everywhere; Ironwood on testnet is fine. + assert_eq!( + resolve_pool_for_network(None, MainNetwork).unwrap(), + ShieldedPool::Orchard + ); + assert_eq!( + resolve_pool_for_network(None, TestNetwork).unwrap(), + ShieldedPool::Ironwood + ); + assert!(resolve_pool_for_network(Some(ShieldedPool::Ironwood), MainNetwork).is_err()); + assert_eq!( + resolve_pool_for_network(Some(ShieldedPool::Orchard), MainNetwork).unwrap(), + ShieldedPool::Orchard + ); + assert_eq!( + resolve_pool_for_network(Some(ShieldedPool::Ironwood), TestNetwork).unwrap(), + ShieldedPool::Ironwood + ); + assert_eq!( + resolve_pool_for_network(Some(ShieldedPool::Sapling), MainNetwork).unwrap(), + ShieldedPool::Sapling + ); + } + #[test] fn absent_or_unknown_pool_label_defaults_to_orchard() { - // Legacy keys.toml has no `pool` field at all. - assert_eq!(pool_from_label(None), ShieldedProtocol::Orchard); + // Legacy keys.toml has no `pool` field at all: still legacy Orchard. + assert_eq!(pool_from_label(None), ShieldedPool::Orchard); // Unknown labels fall back to Orchard rather than erroring on read. - assert_eq!(pool_from_label(Some("bogus")), ShieldedProtocol::Orchard); - assert_eq!(pool_from_label(Some("sapling")), ShieldedProtocol::Sapling); - assert_eq!(pool_from_label(Some("orchard")), ShieldedProtocol::Orchard); + assert_eq!(pool_from_label(Some("bogus")), ShieldedPool::Orchard); + assert_eq!(pool_from_label(Some("sapling")), ShieldedPool::Sapling); + // An explicit legacy "orchard" label stays Orchard; new databases write + // "ironwood". + assert_eq!(pool_from_label(Some("orchard")), ShieldedPool::Orchard); + assert_eq!(pool_from_label(Some("ironwood")), ShieldedPool::Ironwood); } #[test] fn pool_encoding_omits_orchard_and_round_trips() { - // Orchard is omitted so new Orchard databases keep byte-identical + // Orchard is omitted so legacy Orchard databases keep byte-identical // config to pre-pool-field ones. - assert_eq!(pool_encoding(ShieldedProtocol::Orchard), None); + assert_eq!(pool_encoding(ShieldedPool::Orchard), None); assert_eq!( - pool_encoding(ShieldedProtocol::Sapling), + pool_encoding(ShieldedPool::Sapling), Some("sapling".to_owned()) ); + // Ironwood is written explicitly. + assert_eq!( + pool_encoding(ShieldedPool::Ironwood), + Some("ironwood".to_owned()) + ); // Encoding then reading back is the identity on the pool. - for pool in [ShieldedProtocol::Orchard, ShieldedProtocol::Sapling] { + for pool in [ + ShieldedPool::Orchard, + ShieldedPool::Sapling, + ShieldedPool::Ironwood, + ] { let label = pool_encoding(pool); assert_eq!(pool_from_label(label.as_deref()), pool); } diff --git a/crates/zkv/src/data.rs b/crates/zkv/src/data.rs index f03e52f..893df04 100644 --- a/crates/zkv/src/data.rs +++ b/crates/zkv/src/data.rs @@ -26,7 +26,7 @@ use zcash_client_sqlite::{ wallet::init::init_wallet_db, FsBlockDb, WalletDb, }; -use zcash_protocol::consensus::{self, Parameters}; +use zcash_protocol::consensus::Parameters; use crate::error; @@ -42,58 +42,10 @@ const CURRENT_MARKER: &str = "current"; /// of [`list_dbs`] (which skips dotfiles and non-directories). const ONBOARDED_MARKER: &str = ".onboarded"; -#[derive(Clone, Copy, Debug, Default)] -pub enum Network { - #[default] - Main, - Test, -} - -impl Network { - pub fn parse(name: &str) -> Result { - match name { - // Canonical names: "mainnet" / "testnet". Short forms accepted - // for legacy/CLI brevity. - "mainnet" | "main" => Ok(Network::Main), - "testnet" | "test" => Ok(Network::Test), - other => Err(format!( - "Unsupported network: {other:?} (use \"mainnet\" or \"testnet\")", - )), - } - } - - pub fn name(&self) -> &str { - match self { - Network::Test => "testnet", - Network::Main => "mainnet", - } - } - - pub fn ticker(&self) -> &'static str { - match self { - Network::Main => "ZEC", - Network::Test => "TAZ", - } - } -} - -impl From for consensus::Network { - fn from(value: Network) -> Self { - match value { - Network::Test => consensus::Network::TestNetwork, - Network::Main => consensus::Network::MainNetwork, - } - } -} - -impl From for Network { - fn from(value: consensus::Network) -> Self { - match value { - consensus::Network::TestNetwork => Network::Test, - consensus::Network::MainNetwork => Network::Main, - } - } -} +// The network enum lives in `crate::network` (it implements `Parameters`, so +// regtest fits the whole generic wallet stack); re-exported here, its +// historical home, so `data::Network` paths keep working. +pub use crate::network::Network; /// Process-wide override for the data directory, set once at startup by `main` from /// the global `--data-dir` flag. Higher precedence than `$ZKV_DATA`. @@ -434,7 +386,7 @@ pub fn init_dbs( ensure_db_dir(name)?; let (db_cache, db_data) = get_db_paths(name)?; let mut db_cache = FsBlockDb::for_path(db_cache).map_err(error::Error::from)?; - let mut db_data = WalletDb::for_path(db_data, params, SystemClock, OsRng)?; + let mut db_data = open_wallet_conn(db_data, params)?; init_blockmeta_db(&mut db_cache)?; init_wallet_db(&mut db_data, None)?; Ok(db_data) @@ -461,11 +413,47 @@ pub fn open_wallet_db( path: impl AsRef, params: P, ) -> anyhow::Result> { - let mut db_data = WalletDb::for_path(path, params, SystemClock, OsRng)?; + let path = path.as_ref(); + let mut db_data = open_wallet_conn(path, params)?; init_wallet_db(&mut db_data, None)?; Ok(db_data) } +/// Apply the SQLite pragmas zkv relies on for concurrent access to a +/// `data.sqlite`. Used for every handle onto the file: the [`WalletDb`] +/// connection and the short-lived read-path connections +/// ([`crate::internal::state`] / funding / sync). +/// +/// - **`busy_timeout`**: the GUI syncs one database (a commitment-tree write) +/// while its UI reads another, and the read paths open their own connections, +/// so a writer and a reader briefly contend on the same file. Without a busy +/// timeout SQLite returns `SQLITE_BUSY` ("database is locked") *immediately* +/// instead of waiting; a few-second timeout lets the contending side retry +/// transparently rather than failing the whole sync. +/// - **WAL journal mode**: lets readers proceed while a write is in flight (the +/// common GUI pattern), which plain rollback journaling does not. WAL is a +/// persistent per-file property, so once set every later connection uses it. +pub(crate) fn configure_sqlite(conn: &rusqlite::Connection) -> rusqlite::Result<()> { + conn.busy_timeout(std::time::Duration::from_secs(30))?; + // `execute_batch` (sqlite3_exec) ignores the row `PRAGMA journal_mode` + // returns, unlike `pragma_update`. + conn.execute_batch("PRAGMA journal_mode=WAL;")?; + Ok(()) +} + +/// Open a `data.sqlite` [`WalletDb`] on a connection pre-configured by +/// [`configure_sqlite`]. Mirrors `WalletDb::for_path` (open + load the `array` +/// vtab module) but lets us set the pragmas first, via `from_connection`. +fn open_wallet_conn( + path: impl AsRef, + params: P, +) -> anyhow::Result> { + let conn = rusqlite::Connection::open(path)?; + configure_sqlite(&conn)?; + rusqlite::vtab::array::load_module(&conn)?; + Ok(WalletDb::from_connection(conn, params, SystemClock, OsRng)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/zkv/src/db.rs b/crates/zkv/src/db.rs index 5abb655..6f2adb2 100644 --- a/crates/zkv/src/db.rs +++ b/crates/zkv/src/db.rs @@ -94,8 +94,7 @@ pub use crate::protocol::{ }; use zcash_keys::keys::UnifiedFullViewingKey; -use zcash_protocol::consensus; -use zcash_protocol::ShieldedProtocol; +use zcash_protocol::ShieldedPool; use crate::{ config::{Role, WalletConfig}, @@ -109,7 +108,7 @@ use crate::{ cached_version, load_audit, load_history_page, load_state_with_height, wallet_tip, HistoryOrder, }, - sync::run_sync_read, + sync::{run_sync_read, run_sync_read_cancellable, CancelFlag}, write::{ broadcast_init, manage_and_broadcast, prepare, prepare_init, prepare_management, write_and_broadcast, write_many_and_broadcast, BatchItem, PreparedWrite, WriteError, @@ -448,20 +447,25 @@ impl Database { network: Network, conn: ConnectionArgs, ) -> Result<(Self, String)> { - Self::init_admin_with_pool(name, network, ShieldedProtocol::Orchard, conn).await + let pool = crate::config::default_pool_for_network(network); + Self::init_admin_with_pool(name, network, pool, conn).await } /// Like [`Database::init_admin`], but lets the caller pick the shielded - /// pool (Sapling or Orchard) the database lives in. Every memo is read - /// from and written to this pool; it is fixed at creation. + /// pool (Ironwood, Orchard, or Sapling) the database lives in. Every memo is + /// read from and written to this pool; it is fixed at creation. `init_admin` + /// defaults to the network's pool (Ironwood on testnet, Orchard on mainnet). + /// Ironwood shares the Orchard receiver and is rejected on mainnet until + /// NU6.3 activates there ([`ZkvError::Other`]). pub async fn init_admin_with_pool( name: &str, network: Network, - pool: ShieldedProtocol, + pool: ShieldedPool, conn: ConnectionArgs, ) -> Result<(Self, String)> { use bip0039::{Count, Mnemonic}; + ensure_pool_available(pool, network)?; let mnemonic = Mnemonic::generate(Count::Words24); let phrase = mnemonic.phrase().to_owned(); let db = create_admin(name, network, &mnemonic, None, pool, conn).await?; @@ -480,15 +484,8 @@ impl Database { birthday: Option, conn: ConnectionArgs, ) -> Result { - Self::restore_admin_with_pool( - name, - network, - recovery_phrase, - birthday, - ShieldedProtocol::Orchard, - conn, - ) - .await + let pool = crate::config::default_pool_for_network(network); + Self::restore_admin_with_pool(name, network, recovery_phrase, birthday, pool, conn).await } /// Like [`Database::restore_admin`], but lets the caller pick the shielded @@ -500,12 +497,13 @@ impl Database { network: Network, recovery_phrase: &str, birthday: Option, - pool: ShieldedProtocol, + pool: ShieldedPool, conn: ConnectionArgs, ) -> Result { use bip0039::{English, Mnemonic}; use secrecy::Zeroize as _; + ensure_pool_available(pool, network)?; let mut normalized = recovery_phrase .to_lowercase() .split_whitespace() @@ -547,7 +545,8 @@ impl Database { // against a stale/unreachable server's view of the chain. The birthday // is carried by the address, so it is pinned verbatim (no buffer). let mut client = conn.connect(network).await.map_err(ZkvError::Other)?; - let birthday = crate::internal::sync::pinned_birthday(&mut client, parsed.birthday).await?; + let birthday = + crate::internal::sync::pinned_birthday(&mut client, network, parsed.birthday).await?; WalletConfig::init_watch(name, birthday.height(), network, zkv_address, parsed.pool) .map_err(ZkvError::Other)?; @@ -579,7 +578,7 @@ impl Database { /// Mainnet or testnet. pub fn network(&self) -> Network { - Network::from(self.cfg.network) + self.cfg.network } /// The canonical `zkv1…` address for this database (the viewing key under a @@ -777,6 +776,23 @@ impl Database { .map_err(ZkvError::Other) } + /// Like [`Database::sync`], but cooperatively cancellable: when `cancel` + /// flips to `true` the in-flight scan stops at the next block-batch boundary + /// and returns the height reached so far (a partial sync is safe and resumes + /// on the next call). The GUI's background auto-sync loop uses this so + /// pausing halts scanning promptly instead of only at the next cycle; + /// passing `None` is identical to [`Database::sync`]. + pub async fn sync_cancellable(&self, cancel: Option) -> Result { + if let Some(tip) = self.skip_sync_if_blocked()? { + return Ok(tip); + } + run_sync_read_cancellable( + &self.name, &self.conn, /* fetch_mempool_too = */ false, cancel, + ) + .await + .map_err(ZkvError::Other) + } + /// Sync plus pull the current lightwalletd mempool into the local /// wallet. Use this when you want a [`Confirmations::Mempool`] read /// to see arbitrary off-wire mempool writes. Honors `blocksync` and the @@ -1424,8 +1440,8 @@ fn map_write_error(e: anyhow::Error) -> ZkvError { /// part of the identity). fn database_identity( ufvk: &UnifiedFullViewingKey, - pool: ShieldedProtocol, - network: consensus::Network, + pool: ShieldedPool, + network: Network, ) -> Result { use zcash_protocol::consensus::Parameters as _; receiver_domain(ufvk, pool, network.network_type()).map_err(ZkvError::Other) @@ -1437,8 +1453,8 @@ fn database_identity( /// `None` if there is no collision. fn find_duplicate_by_ufvk( ufvk: &UnifiedFullViewingKey, - pool: ShieldedProtocol, - network: consensus::Network, + pool: ShieldedPool, + network: Network, ) -> Result> { let identity = database_identity(ufvk, pool, network)?; for name in crate::data::list_dbs().map_err(ZkvError::Other)? { @@ -1467,7 +1483,7 @@ fn find_duplicate_by_ufvk( /// under a different name. pub fn find_duplicate_database( recovery_phrase: &str, - pool: ShieldedProtocol, + pool: ShieldedPool, network: Network, ) -> Result> { use bip0039::{English, Mnemonic}; @@ -1484,14 +1500,13 @@ pub fn find_duplicate_database( .map_err(|e| ZkvError::Other(anyhow::anyhow!("invalid recovery phrase: {e}")))?; // The normalized phrase is a seed equivalent; wipe the owned copy. normalized.zeroize(); - let params: consensus::Network = network.into(); let mut seed = mnemonic.to_seed(""); - let usk = UnifiedSpendingKey::from_seed(¶ms, &seed, AccountId::ZERO); + let usk = UnifiedSpendingKey::from_seed(&network, &seed, AccountId::ZERO); seed.zeroize(); let ufvk = usk .map_err(|e| ZkvError::Other(anyhow::anyhow!("derive key from phrase: {e}")))? .to_unified_full_viewing_key(); - find_duplicate_by_ufvk(&ufvk, pool, params) + find_duplicate_by_ufvk(&ufvk, pool, network) } /// Look for an already-imported local database that shares the identity of the @@ -1506,19 +1521,32 @@ pub fn find_duplicate_watch_database(zkv_address: &str) -> Result find_duplicate_by_ufvk(&parsed.ufvk, parsed.pool, network) } +/// Reject a pool that isn't available on the target network. Ironwood (NU6.3) +/// is live on testnet but not yet on mainnet; a mainnet database uses plain +/// Orchard instead (the two share the Orchard receiver). Shared by the admin +/// creation paths so the CLI, GUI, and library all enforce the same policy. +fn ensure_pool_available(pool: ShieldedPool, network: Network) -> Result<()> { + if pool == ShieldedPool::Ironwood && !crate::config::ironwood_available(network) { + return Err(ZkvError::Other(anyhow::anyhow!( + "the Ironwood pool is not yet available on mainnet (NU6.3 has not activated \ + there); create the database with the Orchard pool, or use testnet" + ))); + } + Ok(()) +} + async fn create_admin( name: &str, network: Network, mnemonic: &bip0039::Mnemonic, birthday: Option, - pool: ShieldedProtocol, + pool: ShieldedPool, conn: ConnectionArgs, ) -> Result { use secrecy::{SecretVec, Zeroize}; use zcash_client_backend::data_api::WalletWrite; - use zcash_protocol::consensus; - let params: consensus::Network = network.into(); + let params = network; let dir = db_dir(name).map_err(ZkvError::Other)?; if dir.join("keys.toml").exists() { return Err(ZkvError::Other(anyhow::anyhow!( @@ -1550,8 +1578,8 @@ async fn create_admin( // explicit `birthday` verbatim; otherwise default to tip − safety buffer. let mut client = conn.connect(params).await.map_err(ZkvError::Other)?; let birthday_acct = match birthday { - Some(height) => crate::internal::sync::pinned_birthday(&mut client, height).await?, - None => crate::internal::sync::near_tip_birthday(&mut client).await?, + Some(height) => crate::internal::sync::pinned_birthday(&mut client, params, height).await?, + None => crate::internal::sync::near_tip_birthday(&mut client, params).await?, }; WalletConfig::init_admin(name, mnemonic, birthday_acct.height(), params, pool) @@ -1716,7 +1744,7 @@ mod tests { } } - fn ufvk_for(seed: &[u8], network: consensus::Network) -> UnifiedFullViewingKey { + fn ufvk_for(seed: &[u8], network: Network) -> UnifiedFullViewingKey { use zcash_keys::keys::UnifiedSpendingKey; UnifiedSpendingKey::from_seed(&network, seed, zip32::AccountId::ZERO) .unwrap() @@ -1729,8 +1757,8 @@ mod tests { // be stable for the same (seed, pool, network) and differ when any of // them changes, so two genuinely-different databases never collide and // a re-import of the same one always does. - let main = consensus::Network::MainNetwork; - let test = consensus::Network::TestNetwork; + let main = Network::Main; + let test = Network::Test; let seed_a = [0x11u8; 32]; let seed_b = [0x22u8; 32]; @@ -1739,23 +1767,23 @@ mod tests { // Same seed, pool, network: identical identity (the re-import case). assert_eq!( - id(&ufvk_a, ShieldedProtocol::Orchard, main), - id(&ufvk_for(&seed_a, main), ShieldedProtocol::Orchard, main), + id(&ufvk_a, ShieldedPool::Orchard, main), + id(&ufvk_for(&seed_a, main), ShieldedPool::Orchard, main), ); // Different seed: different identity. assert_ne!( - id(&ufvk_a, ShieldedProtocol::Orchard, main), - id(&ufvk_for(&seed_b, main), ShieldedProtocol::Orchard, main), + id(&ufvk_a, ShieldedPool::Orchard, main), + id(&ufvk_for(&seed_b, main), ShieldedPool::Orchard, main), ); // Same seed, different pool: different identity. assert_ne!( - id(&ufvk_a, ShieldedProtocol::Orchard, main), - id(&ufvk_a, ShieldedProtocol::Sapling, main), + id(&ufvk_a, ShieldedPool::Orchard, main), + id(&ufvk_a, ShieldedPool::Sapling, main), ); // Same seed, different network: different identity. assert_ne!( - id(&ufvk_for(&seed_a, main), ShieldedProtocol::Orchard, main), - id(&ufvk_for(&seed_a, test), ShieldedProtocol::Orchard, test), + id(&ufvk_for(&seed_a, main), ShieldedPool::Orchard, main), + id(&ufvk_for(&seed_a, test), ShieldedPool::Orchard, test), ); } diff --git a/crates/zkv/src/demo.rs b/crates/zkv/src/demo.rs index 9e0df7d..f98abf9 100644 --- a/crates/zkv/src/demo.rs +++ b/crates/zkv/src/demo.rs @@ -147,7 +147,8 @@ async fn import_account(conn: &ConnectionArgs) -> anyhow::Result<()> { // Birthday is carried by the demo address, so pin it verbatim (no buffer). // Refuses a stale/unreachable tip before importing the account. let mut client = conn.connect(network).await?; - let birthday = crate::internal::sync::pinned_birthday(&mut client, parsed.birthday).await?; + let birthday = + crate::internal::sync::pinned_birthday(&mut client, network, parsed.birthday).await?; let mut db = data::open_wallet_db(&data_path, network)?; db.import_account_ufvk( @@ -206,13 +207,13 @@ mod tests { #[test] fn demo_address_parses_as_testnet() { - use zcash_protocol::consensus::Network; + use crate::network::Network; let parsed = parse_zkv_addr(DEMO_ZKV_ADDRESS).expect("demo address must parse"); let network = network_from_type(parsed.network).expect("demo address network must be known"); assert!( - matches!(network, Network::TestNetwork), + matches!(network, Network::Test), "demo address should be testnet" ); } diff --git a/crates/zkv/src/gui/assets/api.js b/crates/zkv/src/gui/assets/api.js index c0e9741..1efae5e 100644 --- a/crates/zkv/src/gui/assets/api.js +++ b/crates/zkv/src/gui/assets/api.js @@ -21,6 +21,7 @@ saveLicenses: () => call("save_licenses"), openUrl: (url) => call("open_url", { url }), listDatabases: () => call("list_databases"), + listDatabasesBasic: () => call("list_databases_basic"), detail: (name) => call("detail", { name }), history: (name, opts) => { const o = opts || {}; @@ -131,6 +132,7 @@ return Promise.resolve(); }, listDatabases: () => req("GET", "/api/databases"), + listDatabasesBasic: () => req("GET", "/api/databases?basic=true"), detail: (name) => req("GET", "/api/databases/" + enc(name)), history: (name, opts) => { const o = opts || {}; diff --git a/crates/zkv/src/gui/assets/js/app.js b/crates/zkv/src/gui/assets/js/app.js index 0579c2c..4422ef3 100644 --- a/crates/zkv/src/gui/assets/js/app.js +++ b/crates/zkv/src/gui/assets/js/app.js @@ -74,6 +74,21 @@ function App() { return []; } }, [flash]); + const refreshDatabasesBasic = React.useCallback(async () => { + try { + const basic = await api.listDatabasesBasic(); + setDatabases((prev) => { + const byName = new Map(prev.map((d) => [d.name, d])); + return basic.map((b) => { + const old = byName.get(b.name); + return old && old.detailed ? { ...old, paused: b.paused } : b; + }); + }); + return basic; + } catch (_) { + return []; + } + }, []); const refreshStatus = React.useCallback(async () => { try { const s = await api.status(); @@ -176,8 +191,9 @@ function App() { ); React.useEffect(() => { (async () => { - const dbs = await refreshDatabases(); + const dbs = await refreshDatabasesBasic(); const st = await refreshStatus(); + refreshDatabases(); const userDbs = dbs.filter((d) => d.name !== DEMO_DB_NAME); if (userDbs.length === 0 && !(st && st.onboarded)) setShowOnboarding(true); setBooted(true); @@ -368,20 +384,30 @@ function App() { const doTogglePause = React.useCallback(async () => { if (!activeName) return; const next = !(detail && detail.paused); + const setPausedLocally = (name, value) => { + setDetail((d) => d && d.name === name ? { ...d, paused: value } : d); + setDatabases( + (dbs) => dbs.map((db) => db.name === name ? { ...db, paused: value } : db) + ); + }; + setPausedLocally(activeName, next); try { await api.setPause(activeName, next); - await loadDetail(activeName); + loadDetail(activeName); refreshDatabases(); } catch (e) { + setPausedLocally(activeName, !next); flash("Couldn't change sync: " + e.message, "error"); } }, [activeName, detail, loadDetail, refreshDatabases, flash]); const doTogglePauseAll = React.useCallback(async () => { const next = !(status && status.paused_all); + setStatus((s) => s ? { ...s, paused_all: next } : s); try { await api.pauseAll(next); - await refreshStatus(); + refreshStatus(); } catch (e) { + setStatus((s) => s ? { ...s, paused_all: !next } : s); flash("Couldn't change sync: " + e.message, "error"); } }, [status, refreshStatus, flash]); diff --git a/crates/zkv/src/gui/assets/js/chrome.js b/crates/zkv/src/gui/assets/js/chrome.js index 992090f..63e3ac4 100644 --- a/crates/zkv/src/gui/assets/js/chrome.js +++ b/crates/zkv/src/gui/assets/js/chrome.js @@ -104,10 +104,10 @@ const Sidebar = ({ view, onSelectView, databases, sidebarDbs, truncated, totalCo } ), /* @__PURE__ */ React.createElement("span", null, d.name), - d.unsynced > 0 && /* @__PURE__ */ React.createElement("span", { className: "unread-dot", title: `${d.unsynced} pending` }), + d.detailed && d.unsynced > 0 && /* @__PURE__ */ React.createElement("span", { className: "unread-dot", title: `${d.unsynced} pending` }), d.network === "testnet" && /* @__PURE__ */ React.createElement("span", { className: "net-tag", title: "Testnet database" }, "T"), d.paused && /* @__PURE__ */ React.createElement(PauseGlyph, { size: 11, style: { color: "var(--fg-3)" }, title: "Auto-sync paused" }), - /* @__PURE__ */ React.createElement("span", { className: "meta" }, d.keys) + /* @__PURE__ */ React.createElement("span", { className: "meta" }, d.detailed ? d.keys : /* @__PURE__ */ React.createElement("span", { style: { color: "var(--fg-3)" } }, "\xB7")) ); return /* @__PURE__ */ React.createElement("aside", { className: "sidebar" }, /* @__PURE__ */ React.createElement("div", { className: "sidebar-actions" }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary sm", onClick: onCreate }, /* @__PURE__ */ React.createElement(Icon, { name: "plus", className: "icon" }), " Create"), /* @__PURE__ */ React.createElement("button", { className: "btn secondary sm", onClick: onImport }, /* @__PURE__ */ React.createElement(Icon, { name: "download", className: "icon" }), " Import")), /* @__PURE__ */ React.createElement("div", { className: "sidebar-section" }, /* @__PURE__ */ React.createElement("div", { className: "sidebar-heading" }, /* @__PURE__ */ React.createElement("span", null, "Workspace")), /* @__PURE__ */ React.createElement( "div", diff --git a/crates/zkv/src/gui/assets/js/flows.js b/crates/zkv/src/gui/assets/js/flows.js index 0ca9a93..8b18b1f 100644 --- a/crates/zkv/src/gui/assets/js/flows.js +++ b/crates/zkv/src/gui/assets/js/flows.js @@ -234,16 +234,20 @@ const CreateFlow = ({ onCancel, onCreate, onGeneratePhrase, onInit, pollDb, minI const [initRequired, setInitRequired] = React.useState(0); const words = phrase ? phrase.trim().split(/\s+/) : []; const confirmOk = words.length === 24 && confirmWords[3] === words[3] && confirmWords[11] === words[11] && confirmWords[19] === words[19]; - const defaultPoolFor = (_net) => "orchard"; + const ironwoodAvailable = (net) => net !== "mainnet"; + const defaultPoolFor = (net) => ironwoodAvailable(net) ? "ironwood" : "orchard"; const selectNetwork = (net) => { setNetwork(net); - if (!poolTouched) setPool(defaultPoolFor(net)); + if (!poolTouched) { + setPool(defaultPoolFor(net)); + } else if (!ironwoodAvailable(net) && pool === "ironwood") { + setPool("orchard"); + } }; - const ironwoodSelected = pool === "ironwood"; const selectPool = (p) => { + if (p === "ironwood" && !ironwoodAvailable(network)) return; setPool(p); setPoolTouched(true); - if (p === "ironwood") setNetwork("testnet"); }; const nameOk = /^[A-Za-z0-9_-]{1,24}$/.test(name) && !(existingNames || []).includes(name); const stepDots = ["Configure", "Seed phrase", "Confirm", "Fund", "INIT", "Done"]; @@ -383,7 +387,17 @@ const CreateFlow = ({ onCancel, onCreate, onGeneratePhrase, onInit, pollDb, minI if (e.target.classList.contains("modal-overlay") && !busy) onCancel(); } }, - /* @__PURE__ */ React.createElement("div", { className: "modal create-modal", role: "dialog", "aria-labelledby": "create-title" }, /* @__PURE__ */ React.createElement("div", { className: "modal-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "eyebrow" }, "CREATE DATABASE \xB7 ", stepDots[step].toUpperCase()), /* @__PURE__ */ React.createElement("h2", { id: "create-title" }, step === 1 ? "Database Seed Phrase" : stepDots[step]), stepSubs[step] && /* @__PURE__ */ React.createElement("div", { style: { fontSize: 12.5, color: "var(--fg-3)", marginTop: 4 } }, stepSubs[step])), !busy && /* @__PURE__ */ React.createElement("button", { className: "close", onClick: onCancel, title: "Close" }, /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 16 }))), /* @__PURE__ */ React.createElement("div", { className: "modal-stepper" }, stepDots.map((s, i) => /* @__PURE__ */ React.createElement("div", { key: i, className: "step-dot " + (i < step ? "done" : i === step ? "active" : "") }, /* @__PURE__ */ React.createElement("span", null, i < step ? "\u2713" : i + 1), /* @__PURE__ */ React.createElement("span", { className: "dot-label" }, s)))), step === 0 && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "modal-body" }, errBanner, /* @__PURE__ */ React.createElement("div", { className: "write-fields" }, /* @__PURE__ */ React.createElement("div", { className: "field-block" }, /* @__PURE__ */ React.createElement("label", null, "Database name ", /* @__PURE__ */ React.createElement("span", { style: { color: "var(--fg-3)", fontWeight: 400 } }, "\xB7 local nickname only")), /* @__PURE__ */ React.createElement("input", { className: "input lg mono", value: name, onChange: (e) => setName(e.target.value), maxLength: 24, autoFocus: true }), name && !/^[A-Za-z0-9_-]*$/.test(name) ? /* @__PURE__ */ React.createElement("div", { className: "hint", style: { color: "var(--red-500)" } }, "Use only letters, digits, - and _.") : name.length > 24 ? /* @__PURE__ */ React.createElement("div", { className: "hint", style: { color: "var(--red-500)" } }, "At most 24 characters.") : name && (existingNames || []).includes(name) ? /* @__PURE__ */ React.createElement("div", { className: "hint", style: { color: "var(--red-500)" } }, 'A database named "', name, '" already exists.') : null), /* @__PURE__ */ React.createElement("div", { className: "field-block" }, /* @__PURE__ */ React.createElement("label", null, "Network"), /* @__PURE__ */ React.createElement("div", { className: "seg" }, /* @__PURE__ */ React.createElement("button", { className: network === "mainnet" ? "on" : "", disabled: ironwoodSelected, onClick: () => selectNetwork("mainnet") }, /* @__PURE__ */ React.createElement(Icon, { name: "globe", size: 12 }), " mainnet"), /* @__PURE__ */ React.createElement("button", { className: network === "testnet" ? "on" : "", disabled: ironwoodSelected, onClick: () => selectNetwork("testnet") }, /* @__PURE__ */ React.createElement(Icon, { name: "flask-conical", size: 12 }), " testnet")), network === "testnet" && /* @__PURE__ */ React.createElement("div", { className: "hint", style: { fontSize: 12.5, color: "var(--fg-3)", marginTop: 4 } }, "Receive TAZ from our faucet to get started within limits.")), /* @__PURE__ */ React.createElement("div", { className: "field-block" }, /* @__PURE__ */ React.createElement("label", null, "Shielded pool"), /* @__PURE__ */ React.createElement("div", { className: "seg" }, /* @__PURE__ */ React.createElement("button", { className: pool === "ironwood" ? "on" : "", onClick: () => selectPool("ironwood") }, /* @__PURE__ */ React.createElement(Icon, { name: "trees", size: 12 }), " ironwood"), /* @__PURE__ */ React.createElement("button", { className: pool === "orchard" ? "on" : "", onClick: () => selectPool("orchard") }, /* @__PURE__ */ React.createElement(Icon, { name: "shield", size: 12 }), " orchard"), /* @__PURE__ */ React.createElement("button", { className: pool === "sapling" ? "on" : "", onClick: () => selectPool("sapling") }, /* @__PURE__ */ React.createElement(Icon, { name: "leaf", size: 12 }), " sapling"))), ironwoodSelected && /* @__PURE__ */ React.createElement("div", { className: "callout-flow warn" }, /* @__PURE__ */ React.createElement(Icon, { name: "alert-triangle", size: 16, color: "var(--amber-400)" }), /* @__PURE__ */ React.createElement("div", null, "Ironwood is an upcoming Zcash shielded pool.")))), /* @__PURE__ */ React.createElement("div", { className: "modal-foot" }, counter, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 8 } }, /* @__PURE__ */ React.createElement("button", { className: "btn primary", disabled: !nameOk || busy || ironwoodSelected, onClick: doGenerate }, busy ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "spinner" }), " Generating\u2026") : "Generate phrase \u2192")))), step === 1 && phrase && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "modal-body" }, errBanner, /* @__PURE__ */ React.createElement("div", { className: "form-stack" }, /* @__PURE__ */ React.createElement("div", { className: "callout-flow warn" }, /* @__PURE__ */ React.createElement(Icon, { name: "alert-triangle", size: 16, color: "var(--amber-400)" }), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("strong", { style: { color: "inherit" } }, "Write these 24 words down now."), " They are the only backup of the spending key. Anyone with this phrase can write to this database, and spend its ", currencyFor(network), ".")), /* @__PURE__ */ React.createElement("div", { className: "mnemonic-grid" }, words.map((w, i) => /* @__PURE__ */ React.createElement("div", { key: i, className: "mnemonic-cell" }, /* @__PURE__ */ React.createElement("span", { className: "idx" }, i + 1), /* @__PURE__ */ React.createElement("span", { className: "word" }, w)))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 } }, /* @__PURE__ */ React.createElement("label", { className: "check-row", style: { margin: 0 } }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: acked, onChange: (e) => setAcked(e.target.checked) }), "I have written down all 24 words and stored them offline."), /* @__PURE__ */ React.createElement( + /* @__PURE__ */ React.createElement("div", { className: "modal create-modal", role: "dialog", "aria-labelledby": "create-title" }, /* @__PURE__ */ React.createElement("div", { className: "modal-head" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "eyebrow" }, "CREATE DATABASE \xB7 ", stepDots[step].toUpperCase()), /* @__PURE__ */ React.createElement("h2", { id: "create-title" }, step === 1 ? "Database Seed Phrase" : stepDots[step]), stepSubs[step] && /* @__PURE__ */ React.createElement("div", { style: { fontSize: 12.5, color: "var(--fg-3)", marginTop: 4 } }, stepSubs[step])), !busy && /* @__PURE__ */ React.createElement("button", { className: "close", onClick: onCancel, title: "Close" }, /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 16 }))), /* @__PURE__ */ React.createElement("div", { className: "modal-stepper" }, stepDots.map((s, i) => /* @__PURE__ */ React.createElement("div", { key: i, className: "step-dot " + (i < step ? "done" : i === step ? "active" : "") }, /* @__PURE__ */ React.createElement("span", null, i < step ? "\u2713" : i + 1), /* @__PURE__ */ React.createElement("span", { className: "dot-label" }, s)))), step === 0 && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "modal-body" }, errBanner, /* @__PURE__ */ React.createElement("div", { className: "write-fields" }, /* @__PURE__ */ React.createElement("div", { className: "field-block" }, /* @__PURE__ */ React.createElement("label", null, "Database name ", /* @__PURE__ */ React.createElement("span", { style: { color: "var(--fg-3)", fontWeight: 400 } }, "\xB7 local nickname only")), /* @__PURE__ */ React.createElement("input", { className: "input lg mono", value: name, onChange: (e) => setName(e.target.value), maxLength: 24, autoFocus: true }), name && !/^[A-Za-z0-9_-]*$/.test(name) ? /* @__PURE__ */ React.createElement("div", { className: "hint", style: { color: "var(--red-500)" } }, "Use only letters, digits, - and _.") : name.length > 24 ? /* @__PURE__ */ React.createElement("div", { className: "hint", style: { color: "var(--red-500)" } }, "At most 24 characters.") : name && (existingNames || []).includes(name) ? /* @__PURE__ */ React.createElement("div", { className: "hint", style: { color: "var(--red-500)" } }, 'A database named "', name, '" already exists.') : null), /* @__PURE__ */ React.createElement("div", { className: "field-block" }, /* @__PURE__ */ React.createElement("label", null, "Network"), /* @__PURE__ */ React.createElement("div", { className: "seg" }, /* @__PURE__ */ React.createElement("button", { className: network === "mainnet" ? "on" : "", onClick: () => selectNetwork("mainnet") }, /* @__PURE__ */ React.createElement(Icon, { name: "globe", size: 12 }), " mainnet"), /* @__PURE__ */ React.createElement("button", { className: network === "testnet" ? "on" : "", onClick: () => selectNetwork("testnet") }, /* @__PURE__ */ React.createElement(Icon, { name: "flask-conical", size: 12 }), " testnet")), network === "testnet" && /* @__PURE__ */ React.createElement("div", { className: "hint", style: { fontSize: 12.5, color: "var(--fg-3)", marginTop: 4 } }, "Receive TAZ from our faucet to get started within limits.")), /* @__PURE__ */ React.createElement("div", { className: "field-block" }, /* @__PURE__ */ React.createElement("label", null, "Shielded pool"), /* @__PURE__ */ React.createElement("div", { className: "seg" }, /* @__PURE__ */ React.createElement( + "button", + { + className: pool === "ironwood" ? "on" : "", + disabled: !ironwoodAvailable(network), + title: ironwoodAvailable(network) ? void 0 : "Ironwood is not yet available on mainnet (NU6.3)", + onClick: () => selectPool("ironwood") + }, + /* @__PURE__ */ React.createElement(Icon, { name: "trees", size: 12 }), + " ironwood" + ), /* @__PURE__ */ React.createElement("button", { className: pool === "orchard" ? "on" : "", onClick: () => selectPool("orchard") }, /* @__PURE__ */ React.createElement(Icon, { name: "shield", size: 12 }), " orchard"), /* @__PURE__ */ React.createElement("button", { className: pool === "sapling" ? "on" : "", onClick: () => selectPool("sapling") }, /* @__PURE__ */ React.createElement(Icon, { name: "leaf", size: 12 }), " sapling")), /* @__PURE__ */ React.createElement("div", { className: "hint", style: { fontSize: 12.5, color: "var(--fg-3)", marginTop: 4 } }, ironwoodAvailable(network) ? "Ironwood is the current Orchard pool (NU6.3). Old Orchard wallets import as Ironwood." : "Ironwood (NU6.3) is not yet available on mainnet; mainnet databases use Orchard.")))), /* @__PURE__ */ React.createElement("div", { className: "modal-foot" }, counter, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 8 } }, /* @__PURE__ */ React.createElement("button", { className: "btn primary", disabled: !nameOk || busy, onClick: doGenerate }, busy ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "spinner" }), " Generating\u2026") : "Generate phrase \u2192")))), step === 1 && phrase && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "modal-body" }, errBanner, /* @__PURE__ */ React.createElement("div", { className: "form-stack" }, /* @__PURE__ */ React.createElement("div", { className: "callout-flow warn" }, /* @__PURE__ */ React.createElement(Icon, { name: "alert-triangle", size: 16, color: "var(--amber-400)" }), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("strong", { style: { color: "inherit" } }, "Write these 24 words down now."), " They are the only backup of the spending key. Anyone with this phrase can write to this database, and spend its ", currencyFor(network), ".")), /* @__PURE__ */ React.createElement("div", { className: "mnemonic-grid" }, words.map((w, i) => /* @__PURE__ */ React.createElement("div", { key: i, className: "mnemonic-cell" }, /* @__PURE__ */ React.createElement("span", { className: "idx" }, i + 1), /* @__PURE__ */ React.createElement("span", { className: "word" }, w)))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 } }, /* @__PURE__ */ React.createElement("label", { className: "check-row", style: { margin: 0 } }, /* @__PURE__ */ React.createElement("input", { type: "checkbox", checked: acked, onChange: (e) => setAcked(e.target.checked) }), "I have written down all 24 words and stored them offline."), /* @__PURE__ */ React.createElement( "button", { className: "btn ghost sm", @@ -443,7 +457,7 @@ const ImportFlow = ({ onCancel, onWatch, onRestore, onComplete }) => { const aohState = aoh === "" ? "empty" : isZkvAddr(aoh) ? "addr" : aohIsHeight ? "height" : "invalid"; const fromAddr = aohState === "addr" && !!addrInfo; const effNetwork = fromAddr ? addrInfo.network : network; - const effPool = fromAddr ? addrInfo.pool : "orchard"; + const effPool = fromAddr ? addrInfo.pool : network === "mainnet" ? "orchard" : "ironwood"; const effBirthday = fromAddr ? addrInfo.birthday : aohState === "height" ? Number(aoh) : void 0; React.useEffect(() => { const v = addrOrHeight.trim(); @@ -536,7 +550,7 @@ const ImportFlow = ({ onCancel, onWatch, onRestore, onComplete }) => { onChange: (e) => setAddrOrHeight(e.target.value), disabled: busy } - ), /* @__PURE__ */ React.createElement("div", { className: "addr-preview", "data-state": fromAddr || aohState === "height" ? "parsed" : aohState === "invalid" ? "invalid" : "empty", style: { marginTop: 8 } }, aohState === "empty" && /* @__PURE__ */ React.createElement("span", { className: "prev-empty" }, /* @__PURE__ */ React.createElement(Icon, { name: "link-2", size: 12 }), " required: paste the database's ", /* @__PURE__ */ React.createElement("code", null, "zkv1\u2026"), " address, or enter its birthday height"), aohState === "invalid" && /* @__PURE__ */ React.createElement("span", { className: "prev-invalid" }, /* @__PURE__ */ React.createElement(Icon, { name: "alert-circle", size: 12 }), " enter a ", /* @__PURE__ */ React.createElement("code", null, "zkv1\u2026"), " address or a block height"), aohState === "height" && /* @__PURE__ */ React.createElement("span", { className: "prev-ok", style: { display: "inline-flex", alignItems: "center", gap: 6 } }, /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12 }), " birthday height ", Number(aoh)), aohState === "addr" && inspecting && /* @__PURE__ */ React.createElement("span", { className: "prev-empty" }, /* @__PURE__ */ React.createElement("div", { className: "spinner" }), " reading network and pool\u2026"), aohState === "addr" && !inspecting && !addrInfo && /* @__PURE__ */ React.createElement("span", { className: "prev-invalid" }, /* @__PURE__ */ React.createElement(Icon, { name: "alert-circle", size: 12 }), " couldn't read this address; check it and try again"), aohState === "addr" && !inspecting && addrInfo && /* @__PURE__ */ React.createElement("span", { className: "prev-ok", style: { display: "inline-flex", alignItems: "center", gap: 6 } }, /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12 }), /* @__PURE__ */ React.createElement("span", { className: "net-badge", "data-net": addrInfo.network }, addrInfo.network), /* @__PURE__ */ React.createElement("span", { className: "db-pool" }, addrInfo.pool), /* @__PURE__ */ React.createElement("span", null, "\xB7 birthday ", addrInfo.birthday)))), !fromAddr && /* @__PURE__ */ React.createElement("div", { className: "field-block" }, /* @__PURE__ */ React.createElement("label", null, "Network"), /* @__PURE__ */ React.createElement("div", { className: "seg" }, /* @__PURE__ */ React.createElement("button", { className: network === "mainnet" ? "on" : "", disabled: busy, onClick: () => setNetwork("mainnet") }, /* @__PURE__ */ React.createElement(Icon, { name: "globe", size: 12 }), " mainnet"), /* @__PURE__ */ React.createElement("button", { className: network === "testnet" ? "on" : "", disabled: busy, onClick: () => setNetwork("testnet") }, /* @__PURE__ */ React.createElement(Icon, { name: "flask-conical", size: 12 }), " testnet"))), /* @__PURE__ */ React.createElement("div", { className: "field-block" }, /* @__PURE__ */ React.createElement("label", null, "Local database nickname"), /* @__PURE__ */ React.createElement("input", { className: "input lg", value: nickname, onChange: (e) => setNickname(e.target.value), maxLength: 24, disabled: busy })))), /* @__PURE__ */ React.createElement("div", { className: "modal-foot" }, /* @__PURE__ */ React.createElement("div", { className: "cost", style: { fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--fg-3)" } }, "equivalent to ", /* @__PURE__ */ React.createElement("code", null, "zkv restore ", nickname || "")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 8 } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", onClick: () => setMethod(null), disabled: busy }, "\u2190 Back"), /* @__PURE__ */ React.createElement("button", { className: "btn primary", disabled: !canRestore, onClick: submitRestore }, busy ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "spinner" }), " Restoring\u2026") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Icon, { name: "key-round", className: "icon" }), " Restore")))))); + ), /* @__PURE__ */ React.createElement("div", { className: "addr-preview", "data-state": fromAddr || aohState === "height" ? "parsed" : aohState === "invalid" ? "invalid" : "empty", style: { marginTop: 8 } }, aohState === "empty" && /* @__PURE__ */ React.createElement("span", { className: "prev-empty" }, /* @__PURE__ */ React.createElement(Icon, { name: "link-2", size: 12 }), " required: paste the database's ", /* @__PURE__ */ React.createElement("code", null, "zkv1\u2026"), " address, or enter its birthday height"), aohState === "invalid" && /* @__PURE__ */ React.createElement("span", { className: "prev-invalid" }, /* @__PURE__ */ React.createElement(Icon, { name: "alert-circle", size: 12 }), " enter a ", /* @__PURE__ */ React.createElement("code", null, "zkv1\u2026"), " address or a block height"), aohState === "height" && /* @__PURE__ */ React.createElement("span", { className: "prev-ok", style: { display: "inline-flex", alignItems: "center", gap: 6 } }, /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12 }), " birthday height ", Number(aoh)), aohState === "addr" && inspecting && /* @__PURE__ */ React.createElement("span", { className: "prev-empty" }, /* @__PURE__ */ React.createElement("div", { className: "spinner" }), " reading network and pool\u2026"), aohState === "addr" && !inspecting && !addrInfo && /* @__PURE__ */ React.createElement("span", { className: "prev-invalid" }, /* @__PURE__ */ React.createElement(Icon, { name: "alert-circle", size: 12 }), " couldn't read this address; check it and try again"), aohState === "addr" && !inspecting && addrInfo && /* @__PURE__ */ React.createElement("span", { className: "prev-ok", style: { display: "inline-flex", alignItems: "center", gap: 6 } }, /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12 }), /* @__PURE__ */ React.createElement("span", { className: "net-badge", "data-net": addrInfo.network }, addrInfo.network), /* @__PURE__ */ React.createElement("span", { className: "db-pool" }, addrInfo.pool === "ironwood" ? "ironwood/orchard" : addrInfo.pool), /* @__PURE__ */ React.createElement("span", null, "\xB7 birthday ", addrInfo.birthday)))), !fromAddr && /* @__PURE__ */ React.createElement("div", { className: "field-block" }, /* @__PURE__ */ React.createElement("label", null, "Network"), /* @__PURE__ */ React.createElement("div", { className: "seg" }, /* @__PURE__ */ React.createElement("button", { className: network === "mainnet" ? "on" : "", disabled: busy, onClick: () => setNetwork("mainnet") }, /* @__PURE__ */ React.createElement(Icon, { name: "globe", size: 12 }), " mainnet"), /* @__PURE__ */ React.createElement("button", { className: network === "testnet" ? "on" : "", disabled: busy, onClick: () => setNetwork("testnet") }, /* @__PURE__ */ React.createElement(Icon, { name: "flask-conical", size: 12 }), " testnet"))), /* @__PURE__ */ React.createElement("div", { className: "field-block" }, /* @__PURE__ */ React.createElement("label", null, "Local database nickname"), /* @__PURE__ */ React.createElement("input", { className: "input lg", value: nickname, onChange: (e) => setNickname(e.target.value), maxLength: 24, disabled: busy })))), /* @__PURE__ */ React.createElement("div", { className: "modal-foot" }, /* @__PURE__ */ React.createElement("div", { className: "cost", style: { fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--fg-3)" } }, "equivalent to ", /* @__PURE__ */ React.createElement("code", null, "zkv restore ", nickname || "")), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 8 } }, /* @__PURE__ */ React.createElement("button", { className: "btn secondary", onClick: () => setMethod(null), disabled: busy }, "\u2190 Back"), /* @__PURE__ */ React.createElement("button", { className: "btn primary", disabled: !canRestore, onClick: submitRestore }, busy ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: "spinner" }), " Restoring\u2026") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Icon, { name: "key-round", className: "icon" }), " Restore")))))); }; window.CreateFlow = CreateFlow; window.ImportFlow = ImportFlow; diff --git a/crates/zkv/src/gui/assets/src/api.ts b/crates/zkv/src/gui/assets/src/api.ts index 446bbf2..b6a6a76 100644 --- a/crates/zkv/src/gui/assets/src/api.ts +++ b/crates/zkv/src/gui/assets/src/api.ts @@ -35,6 +35,7 @@ saveLicenses: () => call("save_licenses"), openUrl: (url) => call("open_url", { url }), listDatabases: () => call("list_databases"), + listDatabasesBasic: () => call("list_databases_basic"), detail: (name) => call("detail", { name }), history: (name, opts) => { const o = opts || {}; @@ -155,6 +156,7 @@ return Promise.resolve(); }, listDatabases: () => req("GET", "/api/databases"), + listDatabasesBasic: () => req("GET", "/api/databases?basic=true"), detail: (name) => req("GET", "/api/databases/" + enc(name)), history: (name, opts) => { const o = opts || {}; diff --git a/crates/zkv/src/gui/assets/src/app.tsx b/crates/zkv/src/gui/assets/src/app.tsx index 3e3f83a..fba2d43 100644 --- a/crates/zkv/src/gui/assets/src/app.tsx +++ b/crates/zkv/src/gui/assets/src/app.tsx @@ -124,6 +124,28 @@ function App() { } }, [flash]); + // Fast, wallet-free sidebar list (names + config only) for the first paint on + // launch, so the sidebar appears immediately instead of blocking on a full + // per-database state read. The full `refreshDatabases` fills in the key counts + // a moment later. Merge rather than overwrite so we never downgrade an + // already-detailed row's real counts back to a placeholder (only the paused + // flag, which is authoritative here, is refreshed on such rows). + const refreshDatabasesBasic = React.useCallback(async () => { + try { + const basic = await api.listDatabasesBasic(); + setDatabases((prev) => { + const byName = new Map(prev.map((d) => [d.name, d])); + return basic.map((b) => { + const old = byName.get(b.name); + return old && old.detailed ? { ...old, paused: b.paused } : b; + }); + }); + return basic; + } catch (_) { + return []; + } + }, []); + const refreshStatus = React.useCallback(async () => { try { const s = await api.status(); @@ -241,8 +263,12 @@ function App() { // ---- boot ---- React.useEffect(() => { (async () => { - const dbs = await refreshDatabases(); + // Paint the sidebar from the fast wallet-free list first, so names appear + // immediately, then fill in counts/synced from the full list in the + // background (it summarizes databases concurrently server-side). + const dbs = await refreshDatabasesBasic(); const st = await refreshStatus(); + refreshDatabases(); // Onboarding state lives in the data dir (`.zkv`), not the browser. An // earlier version persisted a "dismissed" flag in localStorage, but that // is per-origin (every zkv install shares http://127.0.0.1:), so a @@ -526,25 +552,43 @@ function App() { }, [activeName, loadDetail, refreshStatus, flash]); // Pause/resume continuous auto-sync for the active db (per-database). + // `setPause` is an instant in-memory flip on the server, but the follow-up + // detail/list reloads are heavy wallet reads that lag badly under load. So + // update the pause indicator optimistically (both the detail panel and the + // sidebar row) instead of waiting on those reloads, and roll back on failure. const doTogglePause = React.useCallback(async () => { if (!activeName) return; const next = !(detail && detail.paused); + const setPausedLocally = (name: string, value: boolean) => { + setDetail((d) => (d && d.name === name ? { ...d, paused: value } : d)); + setDatabases((dbs) => + dbs.map((db) => (db.name === name ? { ...db, paused: value } : db)) + ); + }; + setPausedLocally(activeName, next); try { await api.setPause(activeName, next); - await loadDetail(activeName!); + // Reconcile other fields in the background; the pause flag already shows. + loadDetail(activeName!); refreshDatabases(); } catch (e) { + setPausedLocally(activeName, !next); flash("Couldn't change sync: " + (e as Error).message, "error"); } }, [activeName, detail, loadDetail, refreshDatabases, flash]); // Global "pause all syncing" toggle, surfaced in the bottom status bar. + // Optimistic for the same reason: `pauseAll` flips instantly server-side, but + // `refreshStatus` does a chain-tip probe + wallet open that drags under load, + // so don't make the label wait on it. const doTogglePauseAll = React.useCallback(async () => { const next = !(status && status.paused_all); + setStatus((s) => (s ? { ...s, paused_all: next } : s)); try { await api.pauseAll(next); - await refreshStatus(); + refreshStatus(); } catch (e) { + setStatus((s) => (s ? { ...s, paused_all: !next } : s)); flash("Couldn't change sync: " + (e as Error).message, "error"); } }, [status, refreshStatus, flash]); diff --git a/crates/zkv/src/gui/assets/src/chrome.tsx b/crates/zkv/src/gui/assets/src/chrome.tsx index bca5a00..2c3a1a9 100644 --- a/crates/zkv/src/gui/assets/src/chrome.tsx +++ b/crates/zkv/src/gui/assets/src/chrome.tsx @@ -190,10 +190,12 @@ const Sidebar = ({ view, onSelectView, databases, sidebarDbs, truncated, totalCo {d.name} - {d.unsynced > 0 && } + {d.detailed && d.unsynced > 0 && } {d.network === 'testnet' && T} {d.paused && } - {d.keys} + {/* Until the full list fills counts in, show a muted placeholder instead + of a misleading "0" for a database whose keys haven't loaded yet. */} + {d.detailed ? d.keys : ·} ); return ( diff --git a/crates/zkv/src/gui/assets/src/flows.tsx b/crates/zkv/src/gui/assets/src/flows.tsx index a27d5c0..48cb260 100644 --- a/crates/zkv/src/gui/assets/src/flows.tsx +++ b/crates/zkv/src/gui/assets/src/flows.tsx @@ -552,6 +552,8 @@ const CreateFlow = ({ onCancel, onCreate, onGeneratePhrase, onInit, pollDb, minI const [step, setStep] = React.useState(0); const [name, setName] = React.useState(""); const [network, setNetwork] = React.useState("mainnet"); + // Default pool tracks the network (mainnet → orchard, testnet → ironwood); + // initial network is mainnet, so the initial pool is orchard. const [pool, setPool] = React.useState("orchard"); // Until the user picks a pool explicitly, it tracks the network's default // (see `defaultPoolFor`). @@ -585,22 +587,25 @@ const CreateFlow = ({ onCancel, onCreate, onGeneratePhrase, onInit, pollDb, minI confirmWords[11] === words[11] && confirmWords[19] === words[19]; - // Orchard is live on both mainnet and testnet (NU6.2 has activated on - // testnet), so it is the default pool everywhere. - const defaultPoolFor = (_net: string) => "orchard"; + // Ironwood (the NU6.3 Orchard pool) is testnet-only for now: it is the + // default on testnet, while mainnet defaults to plain Orchard (NU6.3 is not + // yet active there). Ironwood and Orchard share the Orchard receiver, so an + // old Orchard wallet auto-upgrades to Ironwood once NU6.3 reaches mainnet. + const ironwoodAvailable = (net: string) => net !== "mainnet"; + const defaultPoolFor = (net: string) => (ironwoodAvailable(net) ? "ironwood" : "orchard"); const selectNetwork = (net: string) => { setNetwork(net); - if (!poolTouched) setPool(defaultPoolFor(net)); + if (!poolTouched) { + setPool(defaultPoolFor(net)); + } else if (!ironwoodAvailable(net) && pool === "ironwood") { + // Ironwood is disabled on mainnet: fall back to its Orchard equivalent. + setPool("orchard"); + } }; - // Ironwood is an upcoming Zcash shielded pool. It is surfaced as a preview - // option only: it exists on testnet alone and cannot yet be used to create a - // database, so selecting it pins the network to testnet (shown greyed) and - // blocks the rest of the flow. - const ironwoodSelected = pool === "ironwood"; const selectPool = (p: string) => { + if (p === "ironwood" && !ironwoodAvailable(network)) return; setPool(p); setPoolTouched(true); - if (p === "ironwood") setNetwork("testnet"); }; // Local db-name validation, mirrors the backend's data::validate_db_name: @@ -829,10 +834,10 @@ const CreateFlow = ({ onCancel, onCreate, onGeneratePhrase, onInit, pollDb, minI
- -
@@ -845,7 +850,12 @@ const CreateFlow = ({ onCancel, onCreate, onGeneratePhrase, onInit, pollDb, minI
-
-
- {ironwoodSelected && ( -
- -
Ironwood is an upcoming Zcash shielded pool.
+
+ {ironwoodAvailable(network) + ? "Ironwood is the current Orchard pool (NU6.3). Old Orchard wallets import as Ironwood." + : "Ironwood (NU6.3) is not yet available on mainnet; mainnet databases use Orchard."}
- )} +
{counter}
-
@@ -1197,12 +1206,14 @@ const ImportFlow = ({ onCancel, onWatch, onRestore, onComplete }: { const fromAddr = aohState === "addr" && !!addrInfo; // Effective restore parameters. The pasted address wins when resolved; - // otherwise the toggle drives the network and the pool defaults to Orchard - // (live on both networks post-NU6.2; paste a zkv address for a Sapling - // database). The birthday is the address's or the typed height: one is + // otherwise the toggle drives the network and the pool defaults per network + // (Ironwood on testnet, Orchard on mainnet, since NU6.3 is testnet-only for + // now). Ironwood and Orchard share the Orchard receiver, so an old Orchard + // wallet restores losslessly either way. Paste a zkv address for a Sapling + // database. The birthday is the address's or the typed height: one is // required, so there is no implicit default here. const effNetwork = fromAddr ? addrInfo!.network : network; - const effPool = fromAddr ? addrInfo!.pool : "orchard"; + const effPool = fromAddr ? addrInfo!.pool : network === "mainnet" ? "orchard" : "ironwood"; const effBirthday = fromAddr ? addrInfo!.birthday : aohState === "height" ? Number(aoh) : undefined; // When the optional field holds a shape-valid zkv address, resolve its @@ -1506,7 +1517,10 @@ const ImportFlow = ({ onCancel, onWatch, onRestore, onComplete }: { {addrInfo.network} - {addrInfo.pool} + {/* Orchard and Ironwood share the receiver, so an old + Orchard address resolves to Ironwood; label it + "ironwood/orchard" here so importers recognise it. */} + {addrInfo.pool === "ironwood" ? "ironwood/orchard" : addrInfo.pool} · birthday {addrInfo.birthday} )} @@ -1515,7 +1529,7 @@ const ImportFlow = ({ onCancel, onWatch, onRestore, onComplete }: { {/* Network: only when no address is pasted. A pasted address pins the network and pool (shown in the badge above); in the height case the toggle drives the network and the pool defaults to - Orchard. */} + Ironwood. */} {!fromAddr && (
diff --git a/crates/zkv/src/gui/assets/types/zkv-globals.d.ts b/crates/zkv/src/gui/assets/types/zkv-globals.d.ts index fb658c6..5f22e9f 100644 --- a/crates/zkv/src/gui/assets/types/zkv-globals.d.ts +++ b/crates/zkv/src/gui/assets/types/zkv-globals.d.ts @@ -90,6 +90,10 @@ declare global { paused: boolean; updated_at: number | null; synced: number | null; + /// False for rows from the fast launch list (`listDatabasesBasic`): their + /// counts are placeholders until the full list fills them in. Always true + /// from `listDatabases`. + detailed: boolean; } interface KeyStatus { @@ -357,6 +361,7 @@ declare global { saveLicenses(): Promise; openUrl(url: string): Promise; listDatabases(): Promise; + listDatabasesBasic(): Promise; detail(name: string): Promise; history(name: string, opts?: HistoryOpts): Promise; rejections(name: string): Promise; diff --git a/crates/zkv/src/gui/desktop.rs b/crates/zkv/src/gui/desktop.rs index fb1ae28..0f1905a 100644 --- a/crates/zkv/src/gui/desktop.rs +++ b/crates/zkv/src/gui/desktop.rs @@ -79,7 +79,7 @@ mod ipc { use tauri::State; - use zcash_protocol::ShieldedProtocol; + use zcash_protocol::ShieldedPool; use crate::data::Network; use crate::gui::engine::{ @@ -117,10 +117,12 @@ mod ipc { }) } - /// Parse the optional `pool` IPC argument. Absent means Orchard (default). - fn parse_pool(s: Option) -> Result { + /// Parse the optional `pool` IPC argument. Absent means the network's + /// default pool (Ironwood on testnet, Orchard on mainnet); the facade also + /// rejects Ironwood on mainnet. + fn parse_pool(s: Option, network: Network) -> Result { match s { - None => Ok(ShieldedProtocol::Orchard), + None => Ok(crate::config::default_pool_for_network(network)), Some(v) => crate::config::parse_pool(&v).map_err(|message| CmdError { code: "bad_pool", message, @@ -198,6 +200,13 @@ mod ipc { Ok(engine.list_databases().await?) } + /// Fast wallet-free sidebar list (names + config only) for an instant paint + /// on launch; see [`super::super::engine::Engine::list_databases_basic`]. + #[tauri::command(rename_all = "snake_case")] + pub async fn list_databases_basic(engine: E<'_>) -> Result, CmdError> { + Ok(engine.list_databases_basic().await?) + } + #[tauri::command(rename_all = "snake_case")] pub async fn detail(engine: E<'_>, name: String) -> Result { Ok(engine.detail(name).await?) @@ -339,8 +348,9 @@ mod ipc { pool: Option, phrase: Option, ) -> Result { + let net = parse_network(network)?; Ok(engine - .create(name, parse_network(network)?, parse_pool(pool)?, phrase) + .create(name, net, parse_pool(pool, net)?, phrase) .await?) } @@ -399,14 +409,9 @@ mod ipc { pool: Option, birthday: Option, ) -> Result { + let net = parse_network(network)?; Ok(engine - .restore( - name, - phrase, - parse_network(network)?, - parse_pool(pool)?, - birthday, - ) + .restore(name, phrase, net, parse_pool(pool, net)?, birthday) .await?) } @@ -478,6 +483,7 @@ pub fn run(runtime: tokio::runtime::Runtime, conn: ConnectionArgs) -> anyhow::Re ipc::save_licenses, ipc::open_url, ipc::list_databases, + ipc::list_databases_basic, ipc::detail, ipc::history, ipc::rejections, diff --git a/crates/zkv/src/gui/engine.rs b/crates/zkv/src/gui/engine.rs index e533d9d..8d7b2c5 100644 --- a/crates/zkv/src/gui/engine.rs +++ b/crates/zkv/src/gui/engine.rs @@ -30,8 +30,7 @@ use tokio::runtime::Handle; use tokio::sync::{Mutex, Semaphore}; use tokio::task::JoinSet; -use zcash_protocol::consensus; -use zcash_protocol::ShieldedProtocol; +use zcash_protocol::ShieldedPool; use crate::{ config::{Role, WalletConfig}, @@ -136,7 +135,8 @@ pub struct DbSummary { pub name: String, pub role: String, pub network: String, - /// Shielded pool this database lives in: `"sapling"` or `"orchard"`. + /// Shielded pool this database lives in: `"ironwood"`, `"sapling"`, or the + /// legacy `"orchard"` (Ironwood and Orchard share the Orchard receiver). pub pool: String, /// lightwalletd endpoint for this db's network. Resolved locally (no /// round-trip), so the status bar can show the clicked db's server the @@ -159,6 +159,30 @@ pub struct DbSummary { /// the ~100ms before the full detail loads. `None` only if the wallet DB /// can't be opened. pub synced: Option, + /// Whether `keys`/`unsynced`/`updated_at`/`synced` reflect a real state + /// read. The sidebar's first paint comes from [`Engine::list_databases_basic`] + /// (names + config only, no wallet read) so it appears instantly on launch; + /// those rows carry `detailed: false` and zeroed counts, and the frontend + /// renders the count as a placeholder until the full + /// [`Engine::list_databases`] fills it in. Always `true` from the full list. + pub detailed: bool, +} + +/// Memoized inputs for one database's [`DbSummary`], keyed implicitly by the +/// database name in [`Engine::summary_cache`]. Reused across sidebar polls when +/// the wallet's fully-scanned height and the `pending.toml` fingerprint both +/// match, sparing a full `load_state` replay. Not the `synced` field itself +/// (that is read fresh each poll and *is* the cache key). +struct CachedSummary { + /// Fully-scanned height at the time the counts were computed; a change here + /// means new blocks were scanned, so the confirmed state may have moved. + synced: Option, + /// Fingerprint of `pending.toml`; a change means a local write / prune + /// altered the in-flight set the read path merges in. + pending_fp: u64, + keys: usize, + unsynced: usize, + updated_at: Option, } #[derive(Serialize)] @@ -187,7 +211,8 @@ pub struct DbDetail { pub name: String, pub role: String, pub network: String, - /// Shielded pool this database lives in: `"sapling"` or `"orchard"`. + /// Shielded pool this database lives in: `"ironwood"`, `"sapling"`, or the + /// legacy `"orchard"` (Ironwood and Orchard share the Orchard receiver). pub pool: String, /// lightwalletd endpoint for this db's network (e.g. `zec.rocks:443`). /// Resolved locally (no network round-trip), so the status bar can show @@ -514,7 +539,8 @@ pub struct SignPreviewResp { pub struct ZkvAddrInfoResp { /// `"mainnet"` / `"testnet"` / `"regtest"`. pub network: String, - /// Shielded pool this database lives in: `"sapling"` or `"orchard"`. + /// Shielded pool this database lives in: `"ironwood"`, `"sapling"`, or the + /// legacy `"orchard"` (Ironwood and Orchard share the Orchard receiver). pub pool: String, /// The wallet birthday height encoded inside the address. pub birthday: u32, @@ -593,6 +619,21 @@ pub struct Engine { sync_workers: AtomicUsize, /// Global pause: when set, the background loop halts entirely. paused_all: AtomicBool, + /// Cooperative cancel for the *current* auto-sync cycle's in-flight scans. + /// Set when a pause is toggled so scans already running abort promptly (the + /// per-db `paused` set and `paused_all` only gate which databases a cycle + /// *starts*); reset at the top of each cycle. Handed to `db.sync_cancellable` + /// as an [`Arc`] the blocking scan polls between block batches. + sync_cancel: Arc, + /// Memoized sidebar-summary inputs per database. `list_databases` is polled + /// on a timer (every ~10s), and the counts it derives come from a full + /// `load_state` replay per database, which dominates the poll's cost and + /// runs even while syncing is paused (pausing halts the sync loop, not the + /// frontend poll). The confirmed KV state changes only when the wallet scans + /// new blocks (fully-scanned height advances) or a local write mutates + /// `pending.toml`, so cache the derived counts keyed by both and reuse them + /// when neither moved. See [`Engine::list_databases`] / [`CachedSummary`]. + summary_cache: Arc>>, /// Cached build-freshness verdict. The mainnet height probe runs once (on /// the first [`Engine::status`] call) and the answer is reused for the /// process lifetime: we don't care about the long-running edge case where a @@ -609,7 +650,12 @@ impl Engine { // that console should carry only `tracing` log events. See // `ui::set_quiet`. crate::ui::set_quiet(); - let server_label = server_endpoint(&conn, consensus::Network::MainNetwork); + // For the same reason, forbid the reorg-recovery path from blocking on a + // `[y/N]` stdin prompt: a GUI launched from a terminal inherits its TTY, + // so the `is_terminal()` guard alone wouldn't stop a background auto-sync + // from hijacking stdin and hanging the app. + crate::internal::sync::set_interactive_prompts_enabled(false); + let server_label = server_endpoint(&conn, Network::Main); Arc::new(Engine { conn, server_label, @@ -617,6 +663,8 @@ impl Engine { paused: std::sync::Mutex::new(HashSet::new()), sync_workers: AtomicUsize::new(DEFAULT_SYNC_WORKERS), paused_all: AtomicBool::new(false), + sync_cancel: Arc::new(AtomicBool::new(false)), + summary_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), out_of_date: tokio::sync::OnceCell::new(), }) } @@ -648,9 +696,9 @@ impl Engine { let db = run_blocking(move |_| Database::open(&n, conn)).await; if let Ok(db) = db { let net = db.network(); - is_mainnet = matches!(net.into(), consensus::Network::MainNetwork); + is_mainnet = matches!(net, Network::Main); network = Some(net.name().to_owned()); - server = server_endpoint(&self.conn, net.into()); + server = server_endpoint(&self.conn, net); synced = db.synced_height().ok().flatten(); let started = std::time::Instant::now(); if let Ok(tip) = db.chain_tip().await { @@ -672,7 +720,7 @@ impl Engine { chain_tip } else { self.conn - .server_info(consensus::Network::MainNetwork) + .server_info(Network::Main) .await .ok() .map(|i| i.block_height as u32) @@ -713,7 +761,7 @@ impl Engine { /// one network being unreachable (or the operator not serving it) only /// marks that row offline. The two probes run concurrently. pub async fn servers(&self) -> ServersResp { - let probe = |network: consensus::Network| { + let probe = |network: Network| { let conn = self.conn.clone(); async move { let server = server_endpoint(&conn, network); @@ -733,10 +781,7 @@ impl Engine { } } }; - let (mainnet, testnet) = tokio::join!( - probe(consensus::Network::MainNetwork), - probe(consensus::Network::TestNetwork), - ); + let (mainnet, testnet) = tokio::join!(probe(Network::Main), probe(Network::Test),); // Display-formatted (e.g. `~/.zkv` on Unix, the full path on Windows); // best-effort, blank if it can't be resolved. let data_dir = data::data_dir_display().unwrap_or_default(); @@ -757,12 +802,52 @@ impl Engine { } } - /// One summary row per local database (sidebar list). + /// One summary row per local database (sidebar list), with the full key + /// counts / synced height (memoized via `summary_cache`). Databases are + /// summarized concurrently so a many-database workspace doesn't pay the + /// per-db read cost strictly serially (a cold launch would otherwise block + /// the whole sidebar on the sum of every database's replay); output order + /// (the `list_dbs` order) is preserved. + /// + /// The frontend calls [`Engine::list_databases_basic`] first for an instant + /// sidebar and swaps this fuller result in when it lands. pub async fn list_databases(&self) -> Result, ZkvError> { let conn = self.conn.clone(); // Snapshot the per-db paused set (drives the sidebar icon; never the // global pause-all). Cloned out so the closure stays `Send + 'static`. let paused = self.paused.lock().unwrap().clone(); + let cache = self.summary_cache.clone(); + run_blocking(move |_| { + let names = data::list_dbs().map_err(ZkvError::Other)?; + // Each database is an independent set of files; summarize them on + // scoped threads and collect in `names` order. + let out: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = names + .iter() + .map(|name| scope.spawn(|| summarize_db_full(name, &conn, &paused, &cache))) + .collect(); + handles + .into_iter() + .filter_map(|h| h.join().unwrap()) + .collect() + }); + Ok::<_, ZkvError>(out) + }) + .await + } + + /// Fast, wallet-free sidebar list: names plus static config + /// (role/network/pool/server/birthday/paused) only, with zeroed counts and + /// `detailed: false`. Costs one `keys.toml` read per database, no sqlite + /// open and no `load_state` replay, so it returns near-instantly however + /// many databases exist. The frontend paints this on launch so the sidebar + /// appears immediately, then replaces it with the full [`list_databases`] + /// result once the per-db reads finish. + /// + /// [`list_databases`]: Engine::list_databases + pub async fn list_databases_basic(&self) -> Result, ZkvError> { + let conn = self.conn.clone(); + let paused = self.paused.lock().unwrap().clone(); run_blocking(move |_| { let names = data::list_dbs().map_err(ZkvError::Other)?; let mut out = Vec::with_capacity(names.len()); @@ -771,38 +856,20 @@ impl Engine { Ok(c) => c, Err(_) => continue, }; - // Local-only state read for the sidebar counts + scanned - // height. No network. The synced height lets the status bar - // reflect the clicked db immediately, before its detail loads. - let (keys, unsynced, updated_at, synced) = match Database::open(&name, conn.clone()) - { - Ok(db) => { - let synced = db.synced_height().ok().flatten(); - match db.read(Confirmations::Default) { - Ok(result) => { - let (k, u) = count_keys(&result.state); - let updated = - result.state.values().filter_map(|ks| ks.updated_at).max(); - (k, u, updated, synced) - } - Err(_) => (0, 0, None, synced), - } - } - Err(_) => (0, 0, None, None), - }; let is_paused = paused.contains(&name); out.push(DbSummary { name, role: role_str(cfg.role).to_owned(), - network: Network::from(cfg.network).name().to_owned(), + network: cfg.network.name().to_owned(), pool: crate::config::pool_label(cfg.pool).to_owned(), - server: server_endpoint(&conn, Network::from(cfg.network).into()), + server: server_endpoint(&conn, cfg.network), birthday: u32::from(cfg.birthday), - keys, - unsynced, + keys: 0, + unsynced: 0, paused: is_paused, - updated_at, - synced, + updated_at: None, + synced: None, + detailed: false, }); } Ok::<_, ZkvError>(out) @@ -817,7 +884,7 @@ impl Engine { run_blocking(move |h| { let cfg = WalletConfig::read(&name).map_err(|e| classify_unknown(e, &name))?; let db = Database::open(&name, conn.clone())?; - let server = server_endpoint(&conn, db.network().into()); + let server = server_endpoint(&conn, db.network()); let result = db.read(Confirmations::Default)?; let (init, init_done, init_required) = init_parts(&result.init); // Only the uninitialized verdict is provisional (an INIT could still @@ -1282,7 +1349,7 @@ impl Engine { &self, name: String, network: Network, - pool: ShieldedProtocol, + pool: ShieldedPool, phrase: Option, ) -> Result { let lock = self.db_lock(&name); @@ -1388,9 +1455,8 @@ impl Engine { run_blocking(move |_| { let parsed = crate::protocol::parse_zkv_addr(address.trim()).map_err(ZkvError::Other)?; - let network: Network = crate::protocol::network_from_type(parsed.network) - .map_err(ZkvError::Other)? - .into(); + let network: Network = + crate::protocol::network_from_type(parsed.network).map_err(ZkvError::Other)?; Ok::<_, ZkvError>(ZkvAddrInfoResp { network: network.name().to_owned(), pool: crate::config::pool_label(parsed.pool).to_owned(), @@ -1458,7 +1524,7 @@ impl Engine { name: String, phrase: String, network: Network, - pool: ShieldedProtocol, + pool: ShieldedPool, birthday: Option, ) -> Result { // A bare phrase (no zkv address, no height) has no safe starting point: @@ -1524,8 +1590,10 @@ impl Engine { } } - // Drop the in-memory per-db pause flag; nothing references it now. + // Drop the in-memory per-db pause flag and cached summary; nothing + // references them now, and a same-named db later must not inherit them. self.paused.lock().unwrap().remove(&name); + self.summary_cache.lock().unwrap().remove(&name); Ok(OkResp { ok: true }) } @@ -1556,11 +1624,20 @@ impl Engine { /// Pause or resume continuous auto-sync for a single database. In-memory /// only (resets on restart); no wallet IO. pub fn set_pause(&self, name: String, paused: bool) -> PauseResp { - let mut set = self.paused.lock().unwrap(); + { + let mut set = self.paused.lock().unwrap(); + if paused { + set.insert(name); + } else { + set.remove(&name); + } + } + // Abort the current cycle's in-flight scans so a running scan of the + // just-paused database stops promptly rather than finishing its whole + // range. Cancellation is cheap and safe for the other databases too: + // they resume on the next cycle. Only worth signalling on pause. if paused { - set.insert(name); - } else { - set.remove(&name); + self.sync_cancel.store(true, Ordering::Relaxed); } PauseResp { paused } } @@ -1569,6 +1646,12 @@ impl Engine { /// [`Engine::run_auto_sync`] halts entirely. In-memory only. pub fn set_pause_all(&self, paused: bool) -> PauseResp { self.paused_all.store(paused, Ordering::Relaxed); + // Stop the current cycle's in-flight scans immediately instead of + // waiting for it to drain (a single slow database could otherwise keep + // the CPU pegged for the rest of the cycle after the user hit pause). + if paused { + self.sync_cancel.store(true, Ordering::Relaxed); + } PauseResp { paused } } @@ -1632,6 +1715,12 @@ impl Engine { names.into_iter().filter(|n| !paused.contains(n)).collect() }; + // Fresh cancel token for this cycle. A pause toggled mid-cycle flips + // it (see `set_pause`/`set_pause_all`) so in-flight scans abort; a + // new cycle only runs once syncing is unpaused, so clearing it here + // is safe. + self.sync_cancel.store(false, Ordering::Relaxed); + let workers = self.sync_workers.load(Ordering::Relaxed).max(1); let sem = Arc::new(Semaphore::new(workers)); let mut set = JoinSet::new(); @@ -1641,14 +1730,25 @@ impl Engine { set.spawn(async move { // Cap concurrency to `workers`. let _permit = sem.acquire_owned().await.expect("semaphore not closed"); + // A pause toggled while this database waited for a worker + // slot means we should not start its scan at all. Re-checking + // here (not just at cycle start) is what keeps the CPU from + // staying pegged for a full cycle after the user hits pause. + if engine.paused_all.load(Ordering::Relaxed) + || engine.sync_cancel.load(Ordering::Relaxed) + || engine.paused.lock().unwrap().contains(&name) + { + return; + } // Serialize against same-db work (interactive writes, creates). let lock = engine.db_lock(&name); let _guard = lock.lock().await; let conn = engine.conn.clone(); + let cancel = engine.sync_cancel.clone(); let label = name.clone(); if let Err(e) = run_blocking(move |h| { let db = Database::open(&name, conn)?; - h.block_on(db.sync()) + h.block_on(db.sync_cancellable(Some(cancel))) }) .await { @@ -1776,6 +1876,105 @@ fn init_parts(init: &InitState) -> (String, u32, u32) { } } +/// Compute the full sidebar summary for one database: static config plus the +/// key counts / synced height, memoized via `cache` (see [`CachedSummary`]). +/// `None` if the config can't be read (the database is dropped from the list, +/// matching the pre-parallel loop's `continue`). Safe to run concurrently for +/// different databases: each opens its own sqlite files and the shared cache is +/// mutex-guarded. +fn summarize_db_full( + name: &str, + conn: &ConnectionArgs, + paused: &HashSet, + cache: &std::sync::Mutex>, +) -> Option { + let cfg = WalletConfig::read(name).ok()?; + // Local-only state for the sidebar counts + scanned height. No network. + // `synced` (the wallet's fully-scanned height) also lets the status bar + // reflect the clicked db immediately, before its detail loads. + // + // The counts come from a full `load_state` replay, the expensive part. But + // that state only moves when the wallet scans new blocks (`synced` advances) + // or a local write changes `pending.toml`, so key a cache on both and skip + // the replay when neither changed (the steady/paused case). + let (keys, unsynced, updated_at, synced) = match Database::open(name, conn.clone()) { + Ok(db) => { + let synced = db.synced_height().ok().flatten(); + let pending_fp = pending_fingerprint(name); + let cached = { + let map = cache.lock().unwrap(); + map.get(name).and_then(|c| { + (c.synced == synced && c.pending_fp == pending_fp).then_some(( + c.keys, + c.unsynced, + c.updated_at, + )) + }) + }; + match cached { + Some((k, u, updated)) => (k, u, updated, synced), + None => match db.read(Confirmations::Default) { + Ok(result) => { + let (k, u) = count_keys(&result.state); + let updated = result.state.values().filter_map(|ks| ks.updated_at).max(); + cache.lock().unwrap().insert( + name.to_owned(), + CachedSummary { + synced, + pending_fp, + keys: k, + unsynced: u, + updated_at: updated, + }, + ); + (k, u, updated, synced) + } + // Don't cache a failed read: retry next poll. + Err(_) => (0, 0, None, synced), + }, + } + } + Err(_) => (0, 0, None, None), + }; + Some(DbSummary { + name: name.to_owned(), + role: role_str(cfg.role).to_owned(), + network: cfg.network.name().to_owned(), + pool: crate::config::pool_label(cfg.pool).to_owned(), + server: server_endpoint(conn, cfg.network), + birthday: u32::from(cfg.birthday), + keys, + unsynced, + paused: paused.contains(name), + updated_at, + synced, + detailed: true, + }) +} + +/// Cheap content fingerprint of a database's `pending.toml`: the in-flight +/// writes the read path merges in. Two calls hash equal iff the entry set (and +/// each entry's op/key/value) is unchanged, so it flags any append / prune / +/// stale-expiry that would move the sidebar counts. An unreadable file hashes +/// to a distinct sentinel so a broken pending state never serves a stale cache. +fn pending_fingerprint(name: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + match pending::load(name) { + Ok(entries) => { + entries.len().hash(&mut h); + for e in &entries { + e.txid.hash(&mut h); + e.op.hash(&mut h); + e.key.hash(&mut h); + e.value.hash(&mut h); + } + } + Err(_) => u64::MAX.hash(&mut h), + } + h.finish() +} + /// (keys, unsynced) where `keys` is confirmed-or-incoming keys and /// `unsynced` is the subset with in-flight (pending) writes. fn count_keys(state: &BTreeMap) -> (usize, usize) { @@ -2082,7 +2281,7 @@ fn derive_watch_name(addr: &str) -> String { /// Human-facing lightwalletd endpoint, e.g. `zec.rocks:443`. Falls back /// to the operator name when the operator doesn't serve that network. -fn server_endpoint(conn: &ConnectionArgs, network: consensus::Network) -> String { +fn server_endpoint(conn: &ConnectionArgs, network: Network) -> String { match conn.server.pick(network) { Ok(s) => s.to_string(), Err(_) => match &conn.server { diff --git a/crates/zkv/src/gui/mod.rs b/crates/zkv/src/gui/mod.rs index 862d05c..b01e2ad 100644 --- a/crates/zkv/src/gui/mod.rs +++ b/crates/zkv/src/gui/mod.rs @@ -46,7 +46,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; -use zcash_protocol::ShieldedProtocol; +use zcash_protocol::ShieldedPool; use crate::data::Network; use crate::db::ZkvError; @@ -176,6 +176,14 @@ fn router(state: Arc) -> Router { // Request DTOs (axum extractor types) // =================================================================== +#[derive(Deserialize)] +struct ListQuery { + /// When set, return the fast wallet-free summary (names + config only, no + /// key counts) for an instant sidebar paint on launch. + #[serde(default)] + basic: bool, +} + #[derive(Deserialize)] struct HistoryQuery { /// Case-insensitive substring filter on the key. @@ -360,8 +368,16 @@ async fn handle_licenses(State(state): State>) -> Json>) -> Result>, ApiError> { - Ok(Json(state.engine.list_databases().await?)) +async fn handle_list( + State(state): State>, + Query(q): Query, +) -> Result>, ApiError> { + let dbs = if q.basic { + state.engine.list_databases_basic().await? + } else { + state.engine.list_databases().await? + }; + Ok(Json(dbs)) } async fn handle_detail( @@ -550,7 +566,7 @@ async fn handle_create( Json(body): Json, ) -> Result, ApiError> { let network = parse_network(&body.network)?; - let pool = parse_pool(body.pool.as_deref())?; + let pool = parse_pool(body.pool.as_deref(), network)?; Ok(Json( state .engine @@ -615,7 +631,7 @@ async fn handle_restore( Json(body): Json, ) -> Result, ApiError> { let network = parse_network(&body.network)?; - let pool = parse_pool(body.pool.as_deref())?; + let pool = parse_pool(body.pool.as_deref(), network)?; Ok(Json( state .engine @@ -660,11 +676,12 @@ fn parse_network(s: &str) -> Result { }) } -/// Parse the optional `pool` field from a create request. Absent means -/// Orchard (the default pool). -fn parse_pool(s: Option<&str>) -> Result { +/// Parse the optional `pool` field from a create request. Absent means the +/// network's default pool (Ironwood on testnet, Orchard on mainnet). The facade +/// additionally rejects Ironwood on mainnet. +fn parse_pool(s: Option<&str>, network: Network) -> Result { match s { - None => Ok(ShieldedProtocol::Orchard), + None => Ok(crate::config::default_pool_for_network(network)), Some(v) => crate::config::parse_pool(v).map_err(|msg| { ApiError( StatusCode::BAD_REQUEST, @@ -1048,6 +1065,27 @@ mod tests { assert_eq!(body_string(res).await, "[]"); } + #[tokio::test] + async fn api_lists_databases_basic_via_query_param() { + // The `?basic=1` variant (fast launch list) shares the route and the + // `ListQuery` extractor; on an empty data dir it returns the same `[]`. + let dir = std::env::temp_dir().join(format!("zkv-gui-basic-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("ZKV_DATA", &dir); + + let app = router(test_state("secrettoken")); + let res = app + .oneshot(api_req( + "/api/databases?basic=true", + "localhost:8088", + Some("secrettoken"), + )) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(body_string(res).await, "[]"); + } + #[tokio::test] async fn api_history_unknown_db_is_404() { // Routing + the `?key=` Query extractor + error mapping: an unknown diff --git a/crates/zkv/src/internal/funding.rs b/crates/zkv/src/internal/funding.rs index a4a8391..6799869 100644 --- a/crates/zkv/src/internal/funding.rs +++ b/crates/zkv/src/internal/funding.rs @@ -168,6 +168,7 @@ pub fn load_funding( }; let conn = Connection::open(&db_data_path)?; + crate::data::configure_sqlite(&conn)?; // ---- Per-tx outputs: zkv detection + display memo + send recipients ---- let mut agg: HashMap, OutputsAgg> = HashMap::new(); diff --git a/crates/zkv/src/internal/send.rs b/crates/zkv/src/internal/send.rs index 46bb767..59d96e5 100644 --- a/crates/zkv/src/internal/send.rs +++ b/crates/zkv/src/internal/send.rs @@ -11,8 +11,9 @@ use zcash_address::{ConversionError, ZcashAddress}; use zcash_client_backend::{ data_api::{ wallet::{ - create_proposed_transactions, input_selection::GreedyInputSelector, propose_transfer, - ConfirmationsPolicy, SpendingKeys, + create_proposed_transactions, + input_selection::{GreedyInputSelector, SpendPolicy}, + propose_transfer, ConfirmationsPolicy, SpendingKeys, }, Account, WalletRead, }, @@ -23,9 +24,10 @@ use zcash_client_backend::{ use zcash_keys::{address::Address, keys::UnifiedSpendingKey}; use zcash_proofs::prover::LocalTxProver; use zcash_protocol::{ - consensus::{self, NetworkType, Parameters}, + consensus::{NetworkType, Parameters}, memo::{Memo, MemoBytes}, value::Zatoshis, + ShieldedPool, }; use zip321::{Payment, TransactionRequest}; @@ -116,7 +118,10 @@ pub fn parse_zec(input: &str) -> Result { /// Surfaces a friendly reason on failure (unparseable, wrong network, or an /// unsupported kind). Pure: no I/O. Accepts every address type librustzcash /// recognizes. -pub fn validate_recipient(recipient: &str, network: consensus::Network) -> Result { +pub fn validate_recipient( + recipient: &str, + network: crate::network::Network, +) -> Result { describe_recipient(recipient, network).map(|info| info.kind) } @@ -136,7 +141,7 @@ pub struct RecipientInfo { /// failure as [`validate_recipient`]. Pure: no I/O. pub fn describe_recipient( recipient: &str, - network: consensus::Network, + network: crate::network::Network, ) -> Result { let recipient = recipient.trim(); if recipient.is_empty() { @@ -274,10 +279,22 @@ pub async fn pay( tracing::debug!(db = db_name, "creating transaction"); let prover = LocalTxProver::bundled(); + // The change strategy's `fallback_change_pool` is where change lands when + // the transaction has no shielded inputs. It must be a pool the fee/change + // accounting models directly (`OutputManifest` has only Sapling and Orchard + // slots): passing `Ironwood` trips a `total_shielded() == target_change_count` + // assertion in `zcash_client_backend`. Ironwood shares the Orchard pool + // on-chain, and the builder routes Orchard-pool change into the V6 Ironwood + // bundle when NU6.3 is active, so fold Ironwood to Orchard here (matching + // zcash-devtool, which always passes Orchard). + let fallback_change_pool = match config.pool { + ShieldedPool::Ironwood => ShieldedPool::Orchard, + other => other, + }; let change_strategy = MultiOutputChangeStrategy::new( StandardFeeRule::Zip317, None, - config.pool, + fallback_change_pool, DustOutputPolicy::default(), SplitPolicy::with_min_output_value( NonZeroUsize::new(TARGET_NOTE_COUNT).expect("nonzero const"), @@ -296,6 +313,15 @@ pub async fn pay( &change_strategy, request, ConfirmationsPolicy::default(), + // spend_policy (Ironwood RC, transparent-inputs feature): the library + // default is shielded-only (every shielded pool in the build, no + // transparent UTXOs). zkv's funding UA is shielded-only + // (ua_request_for_pool omits the transparent receiver), so writes are + // funded by and spent from shielded notes; there are no transparent + // inputs to select. + &SpendPolicy::default(), + // proposed_version (unstable feature): let the wallet pick the tx version + // for the target height (Ironwood/V6 past NU6.3). None, ) .map_err(error::Error::from)?; @@ -379,7 +405,7 @@ mod tests { #[test] fn validate_recipient_rejects_empty_and_garbage() { - let net = consensus::Network::MainNetwork; + let net = crate::network::Network::Main; assert!(validate_recipient("", net).is_err()); assert!(validate_recipient("not-an-address", net).is_err()); } @@ -392,23 +418,23 @@ mod tests { // branches without fabricating a checksum. let zs = "zs1z7rejlpsa98s2rrrfkwmaxu53e4ue0ulcrw0h4x5g8jl04tak0d3mm47vdtahatqrlkngh9slya"; assert_eq!( - validate_recipient(zs, consensus::Network::MainNetwork).as_deref(), + validate_recipient(zs, crate::network::Network::Main).as_deref(), Ok("sapling"), ); - assert!(validate_recipient(zs, consensus::Network::TestNetwork).is_err()); + assert!(validate_recipient(zs, crate::network::Network::Test).is_err()); } #[test] fn describe_recipient_classifies_kind_and_pool() { // The same verified mainnet Sapling vector as the test above. let zs = "zs1z7rejlpsa98s2rrrfkwmaxu53e4ue0ulcrw0h4x5g8jl04tak0d3mm47vdtahatqrlkngh9slya"; - let info = describe_recipient(zs, consensus::Network::MainNetwork).expect("valid sapling"); + let info = describe_recipient(zs, crate::network::Network::Main).expect("valid sapling"); assert_eq!(info.kind, "sapling"); assert_eq!(info.pool.as_deref(), Some("sapling")); // Wrong network is a clear error, not a misclassification. - assert!(describe_recipient(zs, consensus::Network::TestNetwork).is_err()); + assert!(describe_recipient(zs, crate::network::Network::Test).is_err()); // Empty and garbage are rejected before any network check. - assert!(describe_recipient("", consensus::Network::MainNetwork).is_err()); - assert!(describe_recipient("not-an-address", consensus::Network::MainNetwork).is_err()); + assert!(describe_recipient("", crate::network::Network::Main).is_err()); + assert!(describe_recipient("not-an-address", crate::network::Network::Main).is_err()); } } diff --git a/crates/zkv/src/internal/snapshot.rs b/crates/zkv/src/internal/snapshot.rs index c23ed83..0b7eea0 100644 --- a/crates/zkv/src/internal/snapshot.rs +++ b/crates/zkv/src/internal/snapshot.rs @@ -129,6 +129,10 @@ pub struct PromoteRow { pub fn open(path: &Path) -> Result { let mut conn = Connection::open(path) .map_err(|e| anyhow!("open zkv_state.sqlite at {}: {e}", path.display()))?; + // Same concurrency pragmas as the wallet DB: the read path promotes into the + // snapshot while the GUI may read it, so tolerate brief reader/writer overlap + // instead of failing with "database is locked". + crate::data::configure_sqlite(&conn)?; let version: u32 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; match version { 0 => init_schema(&mut conn)?, diff --git a/crates/zkv/src/internal/state.rs b/crates/zkv/src/internal/state.rs index 866f160..0d7ae13 100644 --- a/crates/zkv/src/internal/state.rs +++ b/crates/zkv/src/internal/state.rs @@ -16,7 +16,7 @@ use rusqlite::{named_params, Connection, OptionalExtension}; use zcash_client_backend::data_api::WalletRead; use zcash_primitives::transaction::TxId; use zcash_protocol::memo::{Memo, MemoBytes}; -use zcash_protocol::ShieldedProtocol; +use zcash_protocol::ShieldedPool; use crate::{ config::WalletConfig, @@ -47,16 +47,35 @@ struct DecodedRow { output_index: u32, } -/// The `v_tx_outputs.output_pool` code for a shielded pool, matching -/// `zcash_client_sqlite`'s `pool_code` (Sapling = 2, Orchard = 3; transparent -/// is 0 and carries no memo). -fn pool_output_code(pool: ShieldedProtocol) -> i64 { +/// The `v_tx_outputs.output_pool` codes for a database's shielded pool, +/// matching `zcash_client_sqlite`'s `pool_code` (Sapling = 2, Orchard = 3, +/// Ironwood = 4; transparent is 0 and carries no memo). +/// +/// The Orchard *value pool* spans two codes: `3` for V5 Orchard outputs and +/// `4` for V6 Ironwood outputs. Ironwood shares the Orchard receiver and value +/// pool, and a post-NU6.3 database's own writes are built as V6 (an Orchard +/// wallet auto-upgrades on its first send), so a single Orchard/Ironwood +/// database's memos can land under *either* code. Read paths must match both, +/// or V6 memos (including this build's own writes on an Ironwood chain) are +/// invisible. A Sapling database stays code 2 only. +fn pool_output_codes(pool: ShieldedPool) -> &'static [i64] { match pool { - ShieldedProtocol::Sapling => 2, - ShieldedProtocol::Orchard => 3, + ShieldedPool::Sapling => &[2], + ShieldedPool::Orchard | ShieldedPool::Ironwood => &[3, 4], } } +/// Render [`pool_output_codes`] as a SQL `IN`-list body (e.g. `"3, 4"`) for +/// inlining into a query. The values are trusted integer constants, never user +/// input, so string interpolation is safe here. +fn pool_in_list(pool: ShieldedPool) -> String { + pool_output_codes(pool) + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(", ") +} + /// Query `data.sqlite` for this database's-pool text memos addressed to this /// account that are strictly past the snapshot `watermark` (plus every unmined /// row), already decoded to text and in chain order @@ -71,15 +90,23 @@ fn scan_memos_past_watermark( account_uuid_bytes: &[u8], tip: u32, watermark: &snapshot::Watermark, - pool: ShieldedProtocol, + pool: ShieldedPool, ) -> anyhow::Result> { - let mut stmt = conn.prepare( - "SELECT v.memo, t.mined_height, t.block_time, v.from_account_uuid, v.txid, v.output_index + // The received note carries its own memo directly. For the wallet's own + // Ironwood self-sends, librustzcash's `backfill_self_send_memos` pass (run + // post-scan in `put_blocks`) fills the received-note memo from the stored raw + // transaction, so no `sent_notes` fallback is needed. (An earlier build read + // the memo via a `COALESCE` scalar subquery against `sent_notes` because the + // pool-4 received note was scanned with a NULL memo; that workaround is gone + // now that the received note is populated at its own pool code.) + let mut stmt = conn.prepare(&format!( + "SELECT v.memo AS memo, + t.mined_height, t.block_time, v.from_account_uuid, v.txid, v.output_index FROM v_tx_outputs v JOIN v_transactions t ON t.txid = v.txid AND t.account_uuid = v.to_account_uuid WHERE v.to_account_uuid = :account_uuid - AND v.output_pool = :output_pool - AND v.memo IS NOT NULL + AND v.output_pool IN ({pools}) + AND (v.memo IS NOT NULL OR v.from_account_uuid = :account_uuid) AND (t.mined_height IS NOT NULL OR t.expiry_height IS NULL OR t.expiry_height = 0 @@ -91,13 +118,13 @@ fn scan_memos_past_watermark( OR (v.txid = :wm_txid AND v.output_index > :wm_output_index)))) ORDER BY t.mined_height ASC NULLS LAST, v.txid ASC, v.output_index ASC", - )?; + pools = pool_in_list(pool), + ))?; let decoded: Vec = stmt .query_and_then( named_params! { ":account_uuid": account_uuid_bytes, - ":output_pool": pool_output_code(pool), ":tip": tip, ":wm_height": watermark.height, ":wm_txid": &watermark.txid, @@ -217,7 +244,7 @@ pub fn wallet_tip(db_name: &str) -> anyhow::Result { /// defaults to 0 before the first wallet summary exists (nothing scanned yet). fn tip_and_fully_scanned( db_data_path: &std::path::Path, - network: zcash_protocol::consensus::Network, + network: crate::network::Network, ) -> anyhow::Result<(u32, u32)> { use zcash_client_backend::data_api::wallet::ConfirmationsPolicy; let db_data = open_wallet_db(db_data_path, network)?; @@ -316,6 +343,7 @@ pub fn load_state_with_height( // empty txid blob), every confirmed row qualifies; `txid > X''` holds for // all non-empty BLOBs. let conn = Connection::open(&db_data_path)?; + crate::data::configure_sqlite(&conn)?; let decoded = scan_memos_past_watermark( &conn, account_uuid_bytes.as_slice(), @@ -521,6 +549,7 @@ pub fn load_history_page( // ---- LIVE: tail past the watermark + pending.toml (small) ---- let conn = Connection::open(&db_data_path)?; + crate::data::configure_sqlite(&conn)?; let decoded = scan_memos_past_watermark( &conn, account_uuid_bytes.as_slice(), @@ -873,6 +902,7 @@ pub fn load_audit(db_name: &str, min_confs: u32) -> anyhow::Result // Full scan: an empty (default) watermark means "every row". let conn = Connection::open(&db_data_path)?; + crate::data::configure_sqlite(&conn)?; let decoded = scan_memos_past_watermark( &conn, account_uuid_bytes.as_slice(), @@ -936,15 +966,15 @@ pub fn load_audit(db_name: &str, min_confs: u32) -> anyhow::Result fn fill_tx_fee_and_output( conn: &Connection, entries: &mut [HistoryEntry], - pool: ShieldedProtocol, + pool: ShieldedPool, ) -> anyhow::Result<()> { let mut tx_stmt = conn.prepare("SELECT account_balance_delta, fee_paid FROM v_transactions WHERE txid = ?1")?; - let mut out_stmt = conn.prepare( + let mut out_stmt = conn.prepare(&format!( "SELECT value FROM v_tx_outputs - WHERE txid = ?1 AND output_index = ?2 AND output_pool = ?3", - )?; - let pool_code = pool_output_code(pool); + WHERE txid = ?1 AND output_index = ?2 AND output_pool IN ({pools})", + pools = pool_in_list(pool), + ))?; // A single tx can carry several writes; cache its fee lookup by txid. let mut fee_cache: HashMap> = HashMap::new(); for e in entries.iter_mut() { @@ -973,9 +1003,7 @@ fn fill_tx_fee_and_output( e.fee = fee; let value: Option = out_stmt - .query_row(rusqlite::params![&blob, e.output_index, pool_code], |row| { - row.get(0) - }) + .query_row(rusqlite::params![&blob, e.output_index], |row| row.get(0)) .optional()?; e.output_value = value.filter(|v| *v > 0).map(|v| v as u64); } diff --git a/crates/zkv/src/internal/sync.rs b/crates/zkv/src/internal/sync.rs index 7dbd382..8ef4aa9 100644 --- a/crates/zkv/src/internal/sync.rs +++ b/crates/zkv/src/internal/sync.rs @@ -5,8 +5,8 @@ use std::collections::BTreeSet; use std::path::Path; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; use anyhow::anyhow; use futures_util::{StreamExt, TryStreamExt}; @@ -34,8 +34,10 @@ use zcash_client_backend::{ }, }; use zcash_client_sqlite::{ - chain::BlockMeta, error::SqliteClientError, util::SystemClock, FsBlockDb, FsBlockDbError, - WalletDb, + chain::{init::init_blockmeta_db, BlockMeta}, + error::SqliteClientError, + util::SystemClock, + FsBlockDb, FsBlockDbError, WalletDb, }; use zcash_keys::encoding::AddressCodec; use zcash_primitives::{ @@ -191,8 +193,18 @@ async fn chain_tip_height(client: &mut CompactTxStreamerClient) -> Resu /// the server's view of the chain current?" guard the database-creation/import /// paths share; it does **not** require the local wallet to have caught up to /// the tip (that is a full sync, a separate thing). -async fn fresh_chain_tip(client: &mut CompactTxStreamerClient) -> Result { +async fn fresh_chain_tip( + client: &mut CompactTxStreamerClient, + network: crate::network::Network, +) -> Result { let height = chain_tip_height(client).await?; + // Regtest block timestamps are synthetic (zebra's regtest genesis is + // dated 2011 and `generate`d blocks advance one second per block), so the + // wall-clock freshness gate can never pass and means nothing there: the + // harness controls mining, so the tip is current by construction. + if network == crate::network::Network::Regtest { + return Ok(height); + } let treestate = client .get_tree_state(BlockId { height: u64::from(height), @@ -231,11 +243,13 @@ async fn account_birthday_at( /// the chain tip minus [`BIRTHDAY_SAFETY_BUFFER`] blocks. Used when *generating* /// a brand-new zkv address (`zkv init`), and as the fallback for `zkv restore` / /// the facade admin-create when no birthday is supplied. Requires a fresh tip -/// ([`TipError::StaleTip`] otherwise). +/// ([`TipError::StaleTip`] otherwise; regtest tips are always accepted, see +/// `fresh_chain_tip`). pub async fn near_tip_birthday( client: &mut CompactTxStreamerClient, + network: crate::network::Network, ) -> Result { - let chain_tip = fresh_chain_tip(client).await?; + let chain_tip = fresh_chain_tip(client, network).await?; let birthday_height = chain_tip.saturating_sub(BIRTHDAY_SAFETY_BUFFER); account_birthday_at(client, birthday_height, chain_tip).await } @@ -243,12 +257,14 @@ pub async fn near_tip_birthday( /// Pin a birthday at an **already-known** height: importing or watching a zkv /// address (birthday carried in the address), or a `zkv restore` with an /// explicit `--birthday`. The height is honored **verbatim** (no safety -/// buffer). Still requires a fresh tip ([`TipError::StaleTip`] otherwise). +/// buffer). Still requires a fresh tip ([`TipError::StaleTip`] otherwise; +/// regtest tips are always accepted, see `fresh_chain_tip`). pub async fn pinned_birthday( client: &mut CompactTxStreamerClient, + network: crate::network::Network, birthday_height: u32, ) -> Result { - let chain_tip = fresh_chain_tip(client).await?; + let chain_tip = fresh_chain_tip(client, network).await?; account_birthday_at(client, birthday_height, chain_tip).await } @@ -280,7 +296,7 @@ pub async fn pinned_birthday_unchecked( pub async fn wallet_synced_to_tip( db_name: &str, conn: &ConnectionArgs, - network: zcash_protocol::consensus::Network, + network: crate::network::Network, ) -> anyhow::Result { let (_, db_data_path) = get_db_paths(db_name)?; let db_data = open_wallet_db(&db_data_path, network)?; @@ -346,6 +362,20 @@ impl SyncProgress { /// an instant cached sync just blinks rather than lingering). const SPINNER_GRACE: std::time::Duration = std::time::Duration::ZERO; +/// A cooperative cancellation flag for a sync in progress. When it flips to +/// `true`, the scan loop stops at the next block-batch boundary and returns the +/// height reached so far. A partial sync is always safe: scanning is resumable +/// (the wallet DB commits per batch) and the snapshot/tail read model tolerates +/// a wallet that trails the tip. Used by the GUI auto-sync loop so pausing +/// halts in-flight scans promptly instead of only at the next cycle. `None` +/// means never cancel (every CLI/manual sync path passes `None`). +pub type CancelFlag = Arc; + +/// Whether a cooperative cancellation has been requested. +fn cancelled(cancel: &Option) -> bool { + cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) +} + /// Back-compat alias for [`run_sync`]. The animated status spinner now lives in /// `run_sync` itself, so every sync path (the CLI commands, the `db::Database` /// facade, and the write path's pre-broadcast sync) shows it uniformly. @@ -373,7 +403,7 @@ pub async fn run_sync( conn: &ConnectionArgs, fetch_mempool_too: bool, ) -> anyhow::Result { - run_sync_tol(db_name, conn, fetch_mempool_too, 0).await + run_sync_tol(db_name, conn, fetch_mempool_too, 0, None).await } /// Read-oriented sync: identical to [`run_sync`], but tolerates being up to @@ -387,7 +417,21 @@ pub async fn run_sync_read( conn: &ConnectionArgs, fetch_mempool_too: bool, ) -> anyhow::Result { - run_sync_tol(db_name, conn, fetch_mempool_too, NEAR_TIP_TOLERANCE).await + run_sync_tol(db_name, conn, fetch_mempool_too, NEAR_TIP_TOLERANCE, None).await +} + +/// Read sync that a caller can cancel cooperatively (see [`CancelFlag`]). Used +/// by the GUI's background auto-sync loop so pausing a database (or pausing all +/// syncing) aborts an in-flight scan promptly instead of letting it run to the +/// end of the cycle. Behaves exactly like [`run_sync_read`] when `cancel` is +/// never set. +pub async fn run_sync_read_cancellable( + db_name: &str, + conn: &ConnectionArgs, + fetch_mempool_too: bool, + cancel: Option, +) -> anyhow::Result { + run_sync_tol(db_name, conn, fetch_mempool_too, NEAR_TIP_TOLERANCE, cancel).await } /// Read sync whose fast-path skip tolerance comes from the request's @@ -408,6 +452,7 @@ pub async fn run_sync_read_confs( conn, fetch_mempool_too, read_tip_tolerance(min_confs), + None, ) .await } @@ -420,6 +465,7 @@ async fn run_sync_tol( conn: &ConnectionArgs, fetch_mempool_too: bool, tip_tolerance: u32, + cancel: Option, ) -> anyhow::Result { // Serialize against any other zkv process touching this database: a chain // scan mutates the wallet DB and the block cache, and two concurrent scans @@ -443,6 +489,7 @@ async fn run_sync_tol( fetch_mempool_too, tip_tolerance, Some(&progress), + &cancel, ) .await; spinner.stop().await; @@ -478,6 +525,35 @@ fn needs_recovery(e: &anyhow::Error) -> bool { msg.contains("no such table: scan_queue") } +/// Whether the reorg/corruption recovery path may block on an interactive +/// stdin `[y/N]` prompt. On (the default) for the `zkv` CLI, where a foreground +/// command owns the terminal. The GUI turns it OFF (`Engine::new` calls +/// [`set_interactive_prompts_enabled`]`(false)`): a GUI process can inherit the +/// launching shell's TTY (e.g. `zkv gui` run from a terminal), so an +/// `is_terminal()` check alone would let a background auto-sync steal stdin and +/// hang the app behind a prompt nobody can answer. +static INTERACTIVE_PROMPTS: AtomicBool = AtomicBool::new(true); + +/// Enable or disable interactive recovery prompts process-wide. See the +/// `INTERACTIVE_PROMPTS` static. The GUI disables them at startup. +pub fn set_interactive_prompts_enabled(enabled: bool) { + INTERACTIVE_PROMPTS.store(enabled, Ordering::Relaxed); +} + +/// Databases already auto-wiped this process (GUI self-heal). Bounds the +/// non-interactive recovery to one wipe per database so a persistent reorg +/// can't drive an endless wipe/rescan loop. +static AUTO_WIPED: Mutex> = Mutex::new(BTreeSet::new()); + +/// Record an auto-wipe for `db_name`; returns true the first time this process +/// wipes it (wipe allowed), false on every subsequent unrecoverable failure. +fn auto_wipe_first_time(db_name: &str) -> bool { + AUTO_WIPED + .lock() + .map(|mut wiped| wiped.insert(db_name.to_owned())) + .unwrap_or(false) +} + fn prompt_for_wipe(db_name: &str, err: &anyhow::Error) -> anyhow::Result { use std::io::{stderr, stdin, BufRead, IsTerminal, Write}; eprintln!(); @@ -498,11 +574,40 @@ fn prompt_for_wipe(db_name: &str, err: &anyhow::Error) -> anyhow::Result { ); return Ok(false); } + // GUI mode disables interactive prompts (a background auto-sync must never + // block on a `[y/N]` stdin read; the GUI can also inherit the launching + // shell's TTY, so `is_terminal()` alone wouldn't stop it). There the + // recovery self-heals: the wiped sidecars (data.sqlite, block cache, + // snapshot) are all re-derivable from the seed/UFVK and the chain, so a + // wipe+resync is safe and the app recovers on its own. Bound it to ONE + // auto-wipe per database per process, though: if a reorg keeps recurring, a + // second wipe would just loop on a full rescan, so surface the error and let + // the user intervene instead. + if !INTERACTIVE_PROMPTS.load(Ordering::Relaxed) { + if auto_wipe_first_time(db_name) { + tracing::warn!( + db = db_name, + "unrecoverable reorg / corrupt sidecar: auto-wiping and resyncing from the chain \ + (one-time self-heal)" + ); + return Ok(true); + } + tracing::warn!( + db = db_name, + "unrecoverable reorg / corrupt sidecar persists after a one-time auto-wipe; not wiping \ + again to avoid a rescan loop. Resync manually by deleting data.sqlite, \ + blockmeta.sqlite, blocks/, and zkv_state.sqlite under the database directory." + ); + return Ok(false); + } + // CLI with a non-TTY stdin (piped): can't confirm, and the user didn't opt + // into auto-wipe, so refuse rather than destroy local state unprompted. if !stdin().is_terminal() { eprintln!( - "stdin is not a TTY; refusing to auto-wipe. Re-run interactively to confirm, or \ - manually delete the sidecar files (data.sqlite, blockmeta.sqlite, blocks/, \ - zkv_state.sqlite) under the database directory." + "Refusing to auto-wipe without an interactive confirmation. Re-run the affected \ + database from an interactive `zkv` CLI to confirm, or manually delete the sidecar \ + files (data.sqlite, blockmeta.sqlite, blocks/, zkv_state.sqlite) under the database \ + directory to force a fresh resync." ); return Ok(false); } @@ -519,6 +624,7 @@ async fn run_sync_inner( fetch_mempool_too: bool, tip_tolerance: u32, progress: Option<&SyncProgress>, + cancel: &Option, ) -> anyhow::Result { let config = WalletConfig::read(db_name)?; let params = config.network; @@ -526,6 +632,14 @@ async fn run_sync_inner( let (fsblockdb_root, db_data_path) = get_db_paths(db_name)?; let fsblockdb_root = fsblockdb_root.as_path(); let mut db_cache = FsBlockDb::for_path(fsblockdb_root).map_err(error::Error::from)?; + // `for_path` opens (creating an empty file if absent) but does not create the + // `compactblocks_meta` schema; only `data::init_dbs` does, at database + // creation. So a block cache that was pruned or deleted from an existing + // database (documented as safe: it rebuilds on next sync) would otherwise + // fail here with "no such table: compactblocks_meta". The migrator is + // idempotent, so running it every sync is cheap and makes the cache + // self-healing. + init_blockmeta_db(&mut db_cache).map_err(|e| anyhow!("init block cache schema: {e:?}"))?; let mut db_data = open_wallet_db(&db_data_path, params)?; let mut client = conn.connect(params).await?; @@ -588,13 +702,22 @@ async fn run_sync_inner( update_subtree_roots(&mut client, &mut db_data).await?; loop { + // Cooperative cancellation: a paused GUI aborts the scan between passes + // (and `sync_pass` checks again per block-batch). Partial progress is + // safe and resumes on the next sync. + if cancelled(cancel) { + info!("sync cancelled; stopping scan early"); + return Ok(db_data.chain_height()?.map(u32::from).unwrap_or(0)); + } if !sync_pass( &mut client, ¶ms, fsblockdb_root, &mut db_cache, &mut db_data, + &db_data_path, progress, + cancel, ) .await? { @@ -602,6 +725,13 @@ async fn run_sync_inner( } } + // If cancellation landed during the last pass, skip the (potentially heavy) + // enhance + mempool work and return what we have. + if cancelled(cancel) { + info!("sync cancelled; skipping enhance"); + return Ok(db_data.chain_height()?.map(u32::from).unwrap_or(0)); + } + // Enhance: fetch full transactions so memos are decrypted into the wallet DB. info!("fetching full transactions to decrypt memos"); enhance(&mut client, ¶ms, &mut db_data).await?; @@ -622,13 +752,19 @@ async fn run_sync_inner( /// One pass: download blocks, scan, repeat as suggested. Returns `true` if a /// retriggering condition (chain tip moved or reorg) suggests we should loop again. +// One more argument than clippy's default cap: this is the download/scan +// pass's full working set (clients, params, both DB handles, progress, cancel), +// threaded rather than bundled to keep the call site self-documenting. +#[allow(clippy::too_many_arguments)] async fn sync_pass( client: &mut CompactTxStreamerClient, params: &P, fsblockdb_root: &Path, db_cache: &mut FsBlockDb, db_data: &mut WalletDb, + db_data_path: &Path, progress: Option<&SyncProgress>, + cancel: &Option, ) -> anyhow::Result { let chain_tip = update_chain_tip(client, db_data).await?; if let Some(p) = progress { @@ -658,6 +794,7 @@ async fn sync_pass( fsblockdb_root, db_cache, db_data, + db_data_path, &chain_state, scan_range, )?; @@ -693,6 +830,15 @@ async fn sync_pass( } }) }) { + // Stop before starting the next batch if a cancellation is pending, so a + // paused GUI stops burning CPU within one batch instead of at the end of + // the full range. Flush the deferred block-cache deletions first. + if cancelled(cancel) { + for deletion in block_deletions { + deletion.await?; + } + return Ok(false); + } let block_meta = download_blocks(client, fsblockdb_root, db_cache, &scan_range).await?; let chain_state = download_chain_state(client, scan_range.block_range().start - 1).await?; let updated = scan_blocks( @@ -700,6 +846,7 @@ async fn sync_pass( fsblockdb_root, db_cache, db_data, + db_data_path, &chain_state, &scan_range, )?; @@ -876,56 +1023,70 @@ impl std::fmt::Display for UnrecoverableRewind { impl std::error::Error for UnrecoverableRewind {} -/// Rewind the wallet to `requested` (chosen below the continuity break at `at_height`), -/// retrying at the shallow bound `at_height - 2` when no valid truncation target exists -/// at or below `requested`. Returns the height actually rewound to. -/// -/// The retry leans on the documented `WalletWrite::truncate_to_height` contract: -/// implementations rewind to the nearest valid target *at or below* the requested height -/// and return it (`zcash_client_sqlite` picks the highest scanned block carrying both -/// sapling and orchard note-commitment-tree checkpoints), so one shallower call is the -/// entire "find a recoverable height" search — this handles young wallets whose lowest -/// shared checkpoint (the birthday anchor) has no `blocks` row. The stored block at -/// `at_height - 1` contradicts the new chain, so any useful rewind must remove it — -/// hence the strict `at_height - 2` bound, which guarantees each pass strictly shrinks -/// the scanned chain instead of re-truncating to the same stale block forever. -/// -/// The error's `safe_rewind_height` is deliberately ignored: upstream computes it as the -/// minimum checkpoint height *without* requiring a scanned block there, so it may name -/// the blocks-row-less birthday anchor (itself an invalid target), and any height below -/// the already-failed `requested` fails a fortiori. -/// -/// Lineage: mirrored in zecd's `sync/engine.rs` — port fixes both ways. -/// -/// TODO(upstream): the one remaining storage-backend coupling here is matching the -/// concrete `SqliteClientError::RequestedRewindInvalid` — `zcash_client_backend`'s -/// `WalletWrite` has no trait-level "rewind invalid" error contract, so reorg recovery is -/// structurally tied to the sqlite backend. With non-SQLite `WalletDb` backends planned -/// (PostgreSQL), propose upstream a trait-level error (or a `truncate_to_height` variant -/// that reports "no valid target at or below" portably) and switch this match to it. +/// Find the highest height that is in the `blocks` table AND has shared +/// sapling+orchard checkpoints, bounded by `max_height`. Used as a shallow-rewind +/// fallback when the requested deep rewind has no valid target below it. +fn find_shallow_rewind_target( + db_data_path: &Path, + max_height: BlockHeight, +) -> anyhow::Result> { + use rusqlite::OptionalExtension; + let conn = rusqlite::Connection::open_with_flags( + db_data_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + // Read-only, so we can't set the journal mode (the file is already WAL from + // the read/write opens); just wait rather than fail if a writer holds the + // lock momentarily. + conn.busy_timeout(std::time::Duration::from_secs(30))?; + let h: Option = conn + .query_row( + "SELECT MAX(blocks.height) FROM blocks + JOIN sapling_tree_checkpoints sc ON sc.checkpoint_id = blocks.height + JOIN orchard_tree_checkpoints oc ON oc.checkpoint_id = blocks.height + WHERE blocks.height <= ?1", + [u32::from(max_height)], + |row| row.get(0), + ) + .optional()? + .flatten(); + Ok(h.map(BlockHeight::from)) +} + fn perform_rewind( db_data: &mut WalletDb, + db_data_path: &Path, at_height: BlockHeight, requested: BlockHeight, ) -> anyhow::Result { match db_data.truncate_to_height(requested) { Ok(h) => Ok(h), - Err(SqliteClientError::RequestedRewindInvalid { .. }) => { - let bound = BlockHeight::from(u32::from(at_height).saturating_sub(2)); - match db_data.truncate_to_height(bound) { - Ok(h) => { - info!( - "Shallow rewind to {h} (no valid target at-or-below requested {requested})" - ); - Ok(h) - } - Err(SqliteClientError::RequestedRewindInvalid { .. }) => Err(UnrecoverableRewind { - at_height, - requested, + Err(SqliteClientError::RequestedRewindInvalid { + safe_rewind_height, .. + }) => { + // First try the safe (deeper) rewind reported by the wallet, if any. + if let Some(safe) = safe_rewind_height.filter(|&s| s < requested) { + info!("No checkpoint at {requested}; trying safe rewind to {safe}"); + if let Ok(h) = db_data.truncate_to_height(safe) { + return Ok(h); } - .into()), - Err(e) => Err(anyhow!("{:?}", e)), } + // Fall back: find the highest valid (blocks ∩ shared checkpoints) at or below + // the actual conflict height. This handles young wallets whose lowest + // shared checkpoint (the birthday anchor) has no `blocks` row. + if let Some(target) = find_shallow_rewind_target(db_data_path, at_height)? { + info!( + "Shallow rewind to {target} (no valid target at-or-below requested {requested})" + ); + return db_data + .truncate_to_height(target) + .map_err(|e| anyhow!("{:?}", e)); + } + Err(UnrecoverableRewind { + at_height, + requested, + } + .into()) } Err(e) => Err(anyhow!("{:?}", e)), } @@ -936,6 +1097,7 @@ fn scan_blocks( fsblockdb_root: &Path, db_cache: &mut FsBlockDb, db_data: &mut WalletDb, + db_data_path: &Path, initial_chain_state: &ChainState, scan_range: &ScanRange, ) -> anyhow::Result { @@ -957,7 +1119,7 @@ fn scan_blocks( err.at_height(), requested ); - let rewind_height = perform_rewind(db_data, err.at_height(), requested)?; + let rewind_height = perform_rewind(db_data, db_data_path, err.at_height(), requested)?; db_cache .with_blocks(Some(rewind_height + 1), None, |block| { let meta = BlockMeta { @@ -1022,6 +1184,15 @@ async fn refresh_utxos( Script(script::Code(reply.script)), ), Some(BlockHeight::from(u32::try_from(reply.height)?)), + // recipient_account / recipient_key_scope / funding_account (added in + // the Ironwood RC): account-attribution metadata we don't have for a + // UTXO discovered via lightwalletd's GetAddressUtxos. The sqlite layer + // resolves the owning account by looking the recipient address up in + // the `addresses` table (put_transparent_output), so None here + // preserves the prior behavior. + None, + None, + None, ) .ok_or(anyhow!("non-standard UTXO")) }) diff --git a/crates/zkv/src/internal/write.rs b/crates/zkv/src/internal/write.rs index bf40de3..7fcfb92 100644 --- a/crates/zkv/src/internal/write.rs +++ b/crates/zkv/src/internal/write.rs @@ -798,7 +798,7 @@ pub(crate) fn augment_insufficient_funds(err: anyhow::Error, db_name: &str) -> a return err; }; let network = WalletConfig::read(db_name) - .map(|c| crate::data::Network::from(c.network)) + .map(|c| c.network) .unwrap_or_default(); WriteError::InsufficientFunds { available: u64::from(*available), diff --git a/crates/zkv/src/lib.rs b/crates/zkv/src/lib.rs index 0f9d749..7519b6f 100644 --- a/crates/zkv/src/lib.rs +++ b/crates/zkv/src/lib.rs @@ -81,6 +81,15 @@ pub mod config; /// **Unstable.** See the crate-level docs for the stability contract. pub mod data; +// NOTE: no outer `///` doc here on purpose, same as `shallow` above: the +// module's inner `//!` docs use bare intra-doc links (`Network`) that must +// resolve in the module's own scope, and an outer doc comment would force the +// concatenated block to resolve at the crate root instead. Summary: the +// consensus network a database lives on (mainnet / testnet / regtest), the +// single `Parameters` value the wallet stack is threaded with; also +// re-exported as `data::Network`. Unstable tier. +pub mod network; + /// Chain-scan / write / send / pending-tx plumbing the CLI sits on top of. /// /// **Unstable.** See the crate-level docs for the stability contract. diff --git a/crates/zkv/src/network.rs b/crates/zkv/src/network.rs new file mode 100644 index 0000000..b4225e5 --- /dev/null +++ b/crates/zkv/src/network.rs @@ -0,0 +1,181 @@ +//! The consensus network a zkv database lives on: mainnet, testnet, or a local +//! regtest chain. +//! +//! librustzcash's own [`zcash_protocol::consensus::Network`] only models +//! main/test, but the whole wallet stack (`WalletDb`, key derivation, address +//! encoding, the sync pipeline) is generic over +//! [`zcash_protocol::consensus::Parameters`]. [`Network`] is the single +//! `Parameters` value zkv threads through that stack so regtest, backed by +//! fixed [`zcash_protocol::local_consensus::LocalNetwork`] activation +//! heights, fits without bifurcating every signature. Re-exported as +//! `data::Network` (its historical home) so existing paths keep working. +//! +//! The regtest activation heights are **fixed** (no config surface): they must +//! match the chain the regtest harness's zebrad runs (see +//! `regtest-harness/src/lib.rs`), and there is exactly one such chain shape. + +use zcash_protocol::consensus::{ + BlockHeight, NetworkType, NetworkUpgrade, Parameters, MAIN_NETWORK, TEST_NETWORK, +}; +use zcash_protocol::local_consensus::LocalNetwork; + +/// The network a zkv database is bound to. `Copy` so it threads by value +/// through the wallet APIs exactly as the upstream `consensus::Network` did. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Network { + /// Production Zcash. + #[default] + Main, + /// The public testnet. + Test, + /// A local regtest chain, for the end-to-end harness. Activation heights + /// are fixed (see `regtest_activation` in this module) and must match the + /// harness's zebrad config. + Regtest, +} + +/// Height at which NU5..NU6 activate on the regtest chain (Orchard live from +/// the first block). +const REGTEST_GENESIS_UPGRADES: u32 = 1; +/// Height at which NU6.1/NU6.2 activate. Not height 1: NU6.1's activation +/// block must carry ZIP-271 lockbox disbursements, and the deferred pool only +/// accrues once NU6 is live, so the harness lets NU6 run for a few blocks +/// first. Must match the harness zebrad config's activation heights. +const REGTEST_NU6_2_HEIGHT: u32 = 4; + +/// Height at which NU6.3 (Ironwood) activates on the regtest chain. Kept a few +/// blocks after NU6.2 (like NU6.2 trails NU6) and **must** agree across all +/// three sides of the harness: this `LocalNetwork`, the zebrad config's +/// `[network.testnet_parameters.activation_heights]` `"NU6.3"` key (emitted only +/// for the Ironwood tier, since stock zebra rejects the unknown key), and the +/// devtool funder's `--activation-heights`. Regtest is a testnet-flavored local +/// chain, so Ironwood is available there (see `config::ironwood_available`). +pub const REGTEST_NU6_3_HEIGHT: u32 = 8; + +/// The fixed regtest chain parameters, as a [`LocalNetwork`]. +// `zcash_unstable` is a librustzcash RUSTFLAGS cfg (nu7/zfuture). We don't set +// it, but the gated fields keep this literal valid if someone builds with +// those NUs enabled. +#[allow(unexpected_cfgs)] +fn regtest_activation() -> LocalNetwork { + let h = Some(BlockHeight::from_u32(REGTEST_GENESIS_UPGRADES)); + let nu62 = Some(BlockHeight::from_u32(REGTEST_NU6_2_HEIGHT)); + let nu63 = Some(BlockHeight::from_u32(REGTEST_NU6_3_HEIGHT)); + LocalNetwork { + overwinter: h, + sapling: h, + blossom: h, + heartwood: h, + canopy: h, + nu5: h, + nu6: h, + nu6_1: nu62, + nu6_2: nu62, + nu6_3: nu63, + #[cfg(zcash_unstable = "nu7")] + nu7: nu63, + #[cfg(zcash_unstable = "zfuture")] + z_future: nu63, + } +} + +impl Network { + /// Parse a CLI/keys.toml network name. Canonical names are `"mainnet"`, + /// `"testnet"`, and `"regtest"`; short forms accepted for legacy/CLI + /// brevity. (The `String` error feeds clap.) + pub fn parse(name: &str) -> Result { + match name { + "mainnet" | "main" => Ok(Network::Main), + "testnet" | "test" => Ok(Network::Test), + "regtest" => Ok(Network::Regtest), + other => Err(format!( + "Unsupported network: {other:?} (use \"mainnet\", \"testnet\" or \"regtest\")", + )), + } + } + + /// The canonical name, as written to `keys.toml` and shown in the CLI/GUI. + pub fn name(&self) -> &'static str { + match self { + Network::Main => "mainnet", + Network::Test => "testnet", + Network::Regtest => "regtest", + } + } + + pub fn ticker(&self) -> &'static str { + match self { + Network::Main => "ZEC", + // Regtest coins are testnet-flavored throwaway money. + Network::Test | Network::Regtest => "TAZ", + } + } +} + +impl Parameters for Network { + fn network_type(&self) -> NetworkType { + match self { + Network::Main => NetworkType::Main, + Network::Test => NetworkType::Test, + Network::Regtest => NetworkType::Regtest, + } + } + + fn activation_height(&self, nu: NetworkUpgrade) -> Option { + match self { + Network::Main => MAIN_NETWORK.activation_height(nu), + Network::Test => TEST_NETWORK.activation_height(nu), + Network::Regtest => regtest_activation().activation_height(nu), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_and_name_round_trip() { + for net in [Network::Main, Network::Test, Network::Regtest] { + assert_eq!(Network::parse(net.name()), Ok(net)); + } + assert_eq!(Network::parse("main"), Ok(Network::Main)); + assert_eq!(Network::parse("test"), Ok(Network::Test)); + assert!(Network::parse("bogus").is_err()); + } + + #[test] + fn network_types_match_upstream() { + assert_eq!(Network::Main.network_type(), NetworkType::Main); + assert_eq!(Network::Test.network_type(), NetworkType::Test); + assert_eq!(Network::Regtest.network_type(), NetworkType::Regtest); + } + + #[test] + fn main_and_test_delegate_to_upstream_heights() { + for nu in [NetworkUpgrade::Sapling, NetworkUpgrade::Nu5] { + assert_eq!( + Network::Main.activation_height(nu), + MAIN_NETWORK.activation_height(nu) + ); + assert_eq!( + Network::Test.activation_height(nu), + TEST_NETWORK.activation_height(nu) + ); + } + } + + #[test] + fn regtest_has_orchard_active_from_the_first_block() { + // network_type drives address HRPs and consensus branch ids; NU5 + // (Orchard) must be live at height 1 so the harness chain carries + // Orchard memos from the start. + let net = Network::Regtest; + assert!(net.is_nu_active(NetworkUpgrade::Nu5, BlockHeight::from_u32(1))); + assert!(net.is_nu_active(NetworkUpgrade::Sapling, BlockHeight::from_u32(1))); + assert_eq!( + net.activation_height(NetworkUpgrade::Nu6_1), + Some(BlockHeight::from_u32(REGTEST_NU6_2_HEIGHT)) + ); + } +} diff --git a/crates/zkv/src/protocol/address.rs b/crates/zkv/src/protocol/address.rs index ecd2565..f9e9e90 100644 --- a/crates/zkv/src/protocol/address.rs +++ b/crates/zkv/src/protocol/address.rs @@ -10,7 +10,7 @@ pub struct ParsedZkvAddr { /// The shielded pool this database delivers memos in, inferred from which /// shielded component the published UFVK carries (Orchard if present, else /// Sapling). - pub pool: ShieldedProtocol, + pub pool: ShieldedPool, } /// Private-use unified typecode carrying zkv metadata: currently just the @@ -87,7 +87,7 @@ pub(crate) fn relabel_hrp(src: &str, new_hrp: &str) -> anyhow::Result { fn zkv_container( ufvk: &UnifiedFullViewingKey, params: &P, - pool: ShieldedProtocol, + pool: ShieldedPool, birthday: u32, ) -> anyhow::Result<(NetworkType, unified::Ufvk)> { let stripped = encode_ufvk_for_pool(ufvk, params, pool); @@ -111,7 +111,7 @@ fn zkv_container( pub fn encode_zkv_addr( ufvk: &UnifiedFullViewingKey, params: &P, - pool: ShieldedProtocol, + pool: ShieldedPool, birthday: u32, ) -> anyhow::Result { let (net, with_meta) = zkv_container(ufvk, params, pool, birthday)?; @@ -141,7 +141,7 @@ pub fn zkv_addr_to_uview(zkv_addr: &str) -> anyhow::Result { pub fn encode_ufvk_for_pool( ufvk: &UnifiedFullViewingKey, params: &P, - pool: ShieldedProtocol, + pool: ShieldedPool, ) -> String { let full = ufvk.encode(params); let (network_type, container) = @@ -150,9 +150,12 @@ pub fn encode_ufvk_for_pool( .items() .into_iter() .filter(|item| match pool { - // Keep the chosen pool; drop the other shielded item. - ShieldedProtocol::Orchard => !matches!(item, unified::Fvk::Sapling(_)), - ShieldedProtocol::Sapling => !matches!(item, unified::Fvk::Orchard(_)), + // Keep the chosen pool; drop the other shielded item. Ironwood shares + // the Orchard receiver, so it keeps the Orchard component. + ShieldedPool::Orchard | ShieldedPool::Ironwood => { + !matches!(item, unified::Fvk::Sapling(_)) + } + ShieldedPool::Sapling => !matches!(item, unified::Fvk::Orchard(_)), }) .collect(); unified::Ufvk::try_from_items(items) @@ -164,10 +167,11 @@ pub fn encode_ufvk_for_pool( /// receiver in that pool only (no transparent receiver; memo writes are /// shielded). zkv databases publish a one-pool UA, so the recipient address a /// writer/broadcaster pays is unambiguous. -pub fn ua_request_for_pool(pool: ShieldedProtocol) -> UnifiedAddressRequest { +pub fn ua_request_for_pool(pool: ShieldedPool) -> UnifiedAddressRequest { match pool { - ShieldedProtocol::Orchard => UnifiedAddressRequest::ORCHARD, - ShieldedProtocol::Sapling => UnifiedAddressRequest::unsafe_custom( + // Ironwood shares the Orchard receiver, so both publish an Orchard-only UA. + ShieldedPool::Orchard | ShieldedPool::Ironwood => UnifiedAddressRequest::ORCHARD, + ShieldedPool::Sapling => UnifiedAddressRequest::unsafe_custom( ReceiverRequirement::Omit, ReceiverRequirement::Require, ReceiverRequirement::Omit, @@ -251,11 +255,21 @@ pub fn parse_zkv_addr(s: &str) -> anyhow::Result { ); } // A zkv database lives in exactly one shielded pool. Infer it from the - // published UFVK: prefer Orchard when present, otherwise Sapling. + // published UFVK: an Orchard receiver means Ironwood on networks where NU6.3 + // is available (testnet), or plain Orchard on mainnet (until NU6.3 activates + // there); otherwise Sapling. Ironwood and Orchard share the Orchard receiver + // and signing domain, so importing an Orchard address is lossless either + // way: history stays valid and the wallet's first send builds the right + // transaction version for the network. + let ironwood_ok = !matches!(network, NetworkType::Main); let pool = if ufvk.orchard().is_some() { - ShieldedProtocol::Orchard + if ironwood_ok { + ShieldedPool::Ironwood + } else { + ShieldedPool::Orchard + } } else if ufvk.sapling().is_some() { - ShieldedProtocol::Sapling + ShieldedPool::Sapling } else { bail!( "this zkv address has no shielded pool (Sapling or Orchard), so it can't \ @@ -272,13 +286,14 @@ pub fn parse_zkv_addr(s: &str) -> anyhow::Result { }) } -/// Convert a parsed zkv address's `NetworkType` to a `consensus::Network`. -pub fn network_from_type(network: NetworkType) -> anyhow::Result { - match network { - NetworkType::Main => Ok(consensus::Network::MainNetwork), - NetworkType::Test => Ok(consensus::Network::TestNetwork), - NetworkType::Regtest => Err(anyhow!("regtest is not supported")), - } +/// Convert a parsed zkv address's `NetworkType` to a [`crate::network::Network`]. +pub fn network_from_type(network: NetworkType) -> anyhow::Result { + use crate::network::Network; + Ok(match network { + NetworkType::Main => Network::Main, + NetworkType::Test => Network::Test, + NetworkType::Regtest => Network::Regtest, + }) } /// Derive the zkv signing pubkey from a UFVK at the fixed scope+index. diff --git a/crates/zkv/src/protocol/mod.rs b/crates/zkv/src/protocol/mod.rs index 1c46334..8a6f21b 100644 --- a/crates/zkv/src/protocol/mod.rs +++ b/crates/zkv/src/protocol/mod.rs @@ -16,7 +16,7 @@ use zcash_keys::keys::{ReceiverRequirement, UnifiedAddressRequest, UnifiedFullVi use zcash_protocol::{ consensus::{self, NetworkType}, memo::{Memo, MemoBytes}, - ShieldedProtocol, + ShieldedPool, }; mod address; diff --git a/crates/zkv/src/protocol/sign.rs b/crates/zkv/src/protocol/sign.rs index 5aa48e8..efac9ca 100644 --- a/crates/zkv/src/protocol/sign.rs +++ b/crates/zkv/src/protocol/sign.rs @@ -41,19 +41,21 @@ pub fn signed_payload(domain: &str, op: Op, key: &str, value: Option<&str>) -> V /// single-pool UA a writer pays), so it cannot disagree with the read key. pub fn receiver_domain( ufvk: &UnifiedFullViewingKey, - pool: ShieldedProtocol, + pool: ShieldedPool, net: NetworkType, ) -> anyhow::Result { let (ua, _) = ufvk .default_address(ua_request_for_pool(pool)) .map_err(|e| anyhow!("derive {pool:?} receiver: {e}"))?; let raw: Vec = match pool { - ShieldedProtocol::Orchard => ua + // Ironwood shares the Orchard receiver, so both bind the Orchard receiver + // bytes: a signature made under one verifies under the other. + ShieldedPool::Orchard | ShieldedPool::Ironwood => ua .orchard() .ok_or_else(|| anyhow!("single-pool UA missing its Orchard receiver"))? .to_raw_address_bytes() .to_vec(), - ShieldedProtocol::Sapling => ua + ShieldedPool::Sapling => ua .sapling() .ok_or_else(|| anyhow!("single-pool UA missing its Sapling receiver"))? .to_bytes() diff --git a/crates/zkv/src/protocol/tests.rs b/crates/zkv/src/protocol/tests.rs index 4214b45..4ad36eb 100644 --- a/crates/zkv/src/protocol/tests.rs +++ b/crates/zkv/src/protocol/tests.rs @@ -189,7 +189,7 @@ fn zkv_addr_encodes_under_zkv_hrp_and_round_trips() { ), ] { let ufvk = sample_ufvk(net, 0x7a); - let addr = encode_zkv_addr(&ufvk, &net, ShieldedProtocol::Orchard, 1_234_567).unwrap(); + let addr = encode_zkv_addr(&ufvk, &net, ShieldedPool::Orchard, 1_234_567).unwrap(); // The address is a single bech32m token under the zkv HRP (no colon, // no birthday suffix). assert!(addr.starts_with(hrp), "got {addr}"); @@ -197,7 +197,15 @@ fn zkv_addr_encodes_under_zkv_hrp_and_round_trips() { let parsed = parse_zkv_addr(&addr).expect("parses"); assert_eq!(parsed.network, nt); - assert_eq!(parsed.pool, ShieldedProtocol::Orchard); + // An Orchard-receiver address resolves to Ironwood where NU6.3 is + // available (testnet) and plain Orchard on mainnet; they share the + // receiver, so importing an old Orchard wallet is lossless either way. + let expected_pool = if nt == NetworkType::Main { + ShieldedPool::Orchard + } else { + ShieldedPool::Ironwood + }; + assert_eq!(parsed.pool, expected_pool); assert_eq!( parsed.birthday, 1_234_567, "birthday rides inside the meta item" @@ -205,13 +213,8 @@ fn zkv_addr_encodes_under_zkv_hrp_and_round_trips() { // Re-encoding the parsed key reproduces the exact address (the meta // item + HRP are deterministic). - let addr2 = encode_zkv_addr( - &parsed.ufvk, - &net, - ShieldedProtocol::Orchard, - parsed.birthday, - ) - .unwrap(); + let addr2 = + encode_zkv_addr(&parsed.ufvk, &net, ShieldedPool::Orchard, parsed.birthday).unwrap(); assert_eq!(addr, addr2); // `--view-key`: the same bytes under the standard uview HRP, which @@ -229,7 +232,7 @@ fn parse_zkv_addr_rejects_plain_uview_and_missing_meta() { let ufvk = sample_ufvk(net, 0x55); // A plain uview (no zkv HRP, no meta) is not a zkv address; the // rejection must guide the user toward enabling it for zkv. - let plain_uview = encode_ufvk_for_pool(&ufvk, &net, ShieldedProtocol::Orchard); + let plain_uview = encode_ufvk_for_pool(&ufvk, &net, ShieldedPool::Orchard); let uview_err = parse_zkv_addr(&plain_uview).unwrap_err().to_string(); assert!( uview_err.contains("viewing key") && uview_err.contains("enabled for zkv"), @@ -493,9 +496,9 @@ fn receiver_domain_binds_network() { let ufvk = UnifiedSpendingKey::from_seed(&main, &seed, zip32::AccountId::ZERO) .unwrap() .to_unified_full_viewing_key(); - let dom_main = receiver_domain(&ufvk, ShieldedProtocol::Orchard, NetworkType::Main).unwrap(); - let dom_test = receiver_domain(&ufvk, ShieldedProtocol::Orchard, NetworkType::Test).unwrap(); - let dom_reg = receiver_domain(&ufvk, ShieldedProtocol::Orchard, NetworkType::Regtest).unwrap(); + let dom_main = receiver_domain(&ufvk, ShieldedPool::Orchard, NetworkType::Main).unwrap(); + let dom_test = receiver_domain(&ufvk, ShieldedPool::Orchard, NetworkType::Test).unwrap(); + let dom_reg = receiver_domain(&ufvk, ShieldedPool::Orchard, NetworkType::Regtest).unwrap(); assert!(dom_main.starts_with("main:")); assert!(dom_test.starts_with("test:")); assert!(dom_reg.starts_with("regtest:")); @@ -804,7 +807,7 @@ fn encode_ufvk_for_orchard_strips_sapling() { "control: USK-derived UFVK should contain sapling", ); - let stripped = encode_ufvk_for_pool(&ufvk, ¶ms, ShieldedProtocol::Orchard); + let stripped = encode_ufvk_for_pool(&ufvk, ¶ms, ShieldedPool::Orchard); let (_, stripped_container) = unified::Ufvk::decode(&stripped).expect("decode stripped"); let items = stripped_container.items(); assert!( @@ -820,10 +823,64 @@ fn encode_ufvk_for_orchard_strips_sapling() { "Orchard-pool UFVK must retain orchard", ); - let zkv_addr = encode_zkv_addr(&ufvk, ¶ms, ShieldedProtocol::Orchard, 3_000_000).unwrap(); + let zkv_addr = encode_zkv_addr(&ufvk, ¶ms, ShieldedPool::Orchard, 3_000_000).unwrap(); let parsed = parse_zkv_addr(&zkv_addr).expect("round-trip parses"); assert_eq!(parsed.birthday, 3_000_000); - assert_eq!(parsed.pool, ShieldedProtocol::Orchard); + // An Orchard-receiver address resolves to Ironwood (shared receiver). + assert_eq!(parsed.pool, ShieldedPool::Ironwood); +} + +/// Ironwood shares the Orchard receiver, so a wallet's Orchard and Ironwood +/// framings are indistinguishable at the address / receiver / signing layer. +/// This is what makes "import an old Orchard wallet as Ironwood" and +/// "auto-upgrade to Ironwood on the first send" lossless: the zkv address, the +/// funding UA, and the signing domain (which binds the receiver bytes, not the +/// pool label) are all byte-identical, so every historical signature still +/// verifies after the relabel and the reader sees the same memos. +#[test] +fn orchard_and_ironwood_are_receiver_identical() { + for (net, nt) in [ + (consensus::Network::MainNetwork, NetworkType::Main), + (consensus::Network::TestNetwork, NetworkType::Test), + ] { + let ufvk = sample_ufvk(net, 0x5a); + + // Same zkv address bytes under either framing. + let orchard_addr = encode_zkv_addr(&ufvk, &net, ShieldedPool::Orchard, 999).unwrap(); + let ironwood_addr = encode_zkv_addr(&ufvk, &net, ShieldedPool::Ironwood, 999).unwrap(); + assert_eq!( + orchard_addr, ironwood_addr, + "Orchard and Ironwood must encode to the identical zkv address" + ); + + // Same signing domain (the raw receiver bytes): a signature produced + // under the Orchard framing verifies under Ironwood and vice versa. + let dom_orchard = receiver_domain(&ufvk, ShieldedPool::Orchard, nt).unwrap(); + let dom_ironwood = receiver_domain(&ufvk, ShieldedPool::Ironwood, nt).unwrap(); + assert_eq!( + dom_orchard, dom_ironwood, + "receiver domain must not depend on the Orchard/Ironwood label" + ); + + // Same funding UA (an Orchard receiver in both). + let (ua_o, _) = ufvk + .default_address(ua_request_for_pool(ShieldedPool::Orchard)) + .unwrap(); + let (ua_i, _) = ufvk + .default_address(ua_request_for_pool(ShieldedPool::Ironwood)) + .unwrap(); + assert_eq!(ua_o.encode(&net), ua_i.encode(&net)); + + // Importing an Orchard-receiver address yields Ironwood where NU6.3 is + // available (testnet) and plain Orchard on mainnet; either way it reads + // the identical memos, since the receiver is the same. + let expected = if nt == NetworkType::Main { + ShieldedPool::Orchard + } else { + ShieldedPool::Ironwood + }; + assert_eq!(parse_zkv_addr(&orchard_addr).unwrap().pool, expected); + } } #[test] @@ -836,7 +893,7 @@ fn encode_ufvk_for_sapling_strips_orchard() { UnifiedSpendingKey::from_seed(¶ms, &seed, zip32::AccountId::ZERO).expect("derive USK"); let ufvk = usk.to_unified_full_viewing_key(); - let stripped = encode_ufvk_for_pool(&ufvk, ¶ms, ShieldedProtocol::Sapling); + let stripped = encode_ufvk_for_pool(&ufvk, ¶ms, ShieldedPool::Sapling); let (_, stripped_container) = unified::Ufvk::decode(&stripped).expect("decode stripped"); let items = stripped_container.items(); assert!( @@ -854,10 +911,10 @@ fn encode_ufvk_for_sapling_strips_orchard() { // A Sapling-only zkv address parses, infers the Sapling pool, and // still derives the (transparent-based) signing pubkey. - let zkv_addr = encode_zkv_addr(&ufvk, ¶ms, ShieldedProtocol::Sapling, 2_500_000).unwrap(); + let zkv_addr = encode_zkv_addr(&ufvk, ¶ms, ShieldedPool::Sapling, 2_500_000).unwrap(); let parsed = parse_zkv_addr(&zkv_addr).expect("sapling address parses"); assert_eq!(parsed.birthday, 2_500_000); - assert_eq!(parsed.pool, ShieldedProtocol::Sapling); + assert_eq!(parsed.pool, ShieldedPool::Sapling); zkv_verifying_pubkey(&parsed.ufvk).expect("sapling address still has a signing pubkey"); } @@ -872,7 +929,11 @@ fn funding_ua_never_includes_a_transparent_receiver() { let params = consensus::Network::TestNetwork; let ufvk = sample_ufvk(params, 0x42); - for pool in [ShieldedProtocol::Orchard, ShieldedProtocol::Sapling] { + for pool in [ + ShieldedPool::Orchard, + ShieldedPool::Sapling, + ShieldedPool::Ironwood, + ] { let (ua, _) = ufvk .default_address(ua_request_for_pool(pool)) .unwrap_or_else(|e| panic!("derive {pool:?} funding UA: {e}")); @@ -887,11 +948,12 @@ fn funding_ua_never_includes_a_transparent_receiver() { // The matching shielded receiver IS present (sanity: we didn't // produce an empty/wrong-pool UA). match pool { - ShieldedProtocol::Orchard => assert!( + // Ironwood shares the Orchard receiver. + ShieldedPool::Orchard | ShieldedPool::Ironwood => assert!( ua.orchard().is_some() && ua.sapling().is_none(), - "Orchard funding UA must carry only an orchard receiver", + "Orchard/Ironwood funding UA must carry only an orchard receiver", ), - ShieldedProtocol::Sapling => assert!( + ShieldedPool::Sapling => assert!( ua.sapling().is_some() && ua.orchard().is_none(), "Sapling funding UA must carry only a sapling receiver", ), diff --git a/crates/zkv/src/remote.rs b/crates/zkv/src/remote.rs index 400982b..caeda5d 100644 --- a/crates/zkv/src/remote.rs +++ b/crates/zkv/src/remote.rs @@ -12,8 +12,8 @@ use tracing::info; use zcash_client_backend::proto::service::{ self, compact_tx_streamer_client::CompactTxStreamerClient, }; -use zcash_protocol::consensus::Network; +use crate::network::Network; use crate::socks::SocksConnector; /// Bound the TCP+TLS handshake. A fresh connect right after a laptop wakes @@ -76,12 +76,16 @@ pub enum ServerOperator { impl ServerOperator { fn servers(&self, network: Network) -> &[Server<'_>] { match (self, network) { - (ServerOperator::Ecc, Network::MainNetwork) => &[], - (ServerOperator::Ecc, Network::TestNetwork) => ECC_TESTNET, - (ServerOperator::YWallet, Network::MainNetwork) => YWALLET_MAINNET, - (ServerOperator::YWallet, Network::TestNetwork) => &[], - (ServerOperator::ZecRocks, Network::MainNetwork) => ZEC_ROCKS_MAINNET, - (ServerOperator::ZecRocks, Network::TestNetwork) => ZEC_ROCKS_TESTNET, + (ServerOperator::Ecc, Network::Main) => &[], + (ServerOperator::Ecc, Network::Test) => ECC_TESTNET, + (ServerOperator::YWallet, Network::Main) => YWALLET_MAINNET, + (ServerOperator::YWallet, Network::Test) => &[], + (ServerOperator::ZecRocks, Network::Main) => ZEC_ROCKS_MAINNET, + (ServerOperator::ZecRocks, Network::Test) => ZEC_ROCKS_TESTNET, + // No operator hosts a public regtest chain; a regtest database + // needs an explicit `--server host:port` pointing at the local + // lightwalletd (see `Servers::pick`'s error). + (_, Network::Regtest) => &[], } } } @@ -118,10 +122,18 @@ impl Servers { pub fn pick(&self, network: Network) -> anyhow::Result<&Server<'_>> { match self { - Servers::Hosted(server_operator) => server_operator - .servers(network) - .first() - .ok_or(anyhow!("{:?} doesn't serve {:?}", server_operator, network)), + Servers::Hosted(server_operator) => { + server_operator.servers(network).first().ok_or_else(|| { + if network == Network::Regtest { + anyhow!( + "no hosted lightwalletd serves regtest; pass \ + `--server :` pointing at your local one" + ) + } else { + anyhow!("{:?} doesn't serve {:?}", server_operator, network) + } + }) + } Servers::Custom(servers) => Ok(servers.first().expect("not empty")), } } @@ -279,8 +291,11 @@ impl ConnectionArgs { /// was given, otherwise the default `server`. fn servers_for(&self, network: Network) -> &Servers { match network { - Network::MainNetwork => self.mainnet_server.as_ref().unwrap_or(&self.server), - Network::TestNetwork => self.testnet_server.as_ref().unwrap_or(&self.server), + Network::Main => self.mainnet_server.as_ref().unwrap_or(&self.server), + Network::Test => self.testnet_server.as_ref().unwrap_or(&self.server), + // Regtest has no per-network override flag; the explicit + // `--server host:port` is the only sensible configuration. + Network::Regtest => &self.server, } } @@ -367,17 +382,16 @@ fn backend_label(subversion: &str, vendor: &str) -> String { /// Human label for a network, for error messages. fn network_label(n: Network) -> &'static str { - match n { - Network::MainNetwork => "mainnet", - Network::TestNetwork => "testnet", - } + n.name() } -/// Map a lightwalletd `chain_name` (`"main"` / `"test"`) to a [`Network`]. +/// Map a lightwalletd `chain_name` (`"main"` / `"test"` / `"regtest"`) to a +/// [`Network`]. fn network_from_chain_name(chain_name: &str) -> Option { match chain_name { - "main" => Some(Network::MainNetwork), - "test" => Some(Network::TestNetwork), + "main" => Some(Network::Main), + "test" => Some(Network::Test), + "regtest" => Some(Network::Regtest), _ => None, } } @@ -402,6 +416,13 @@ async fn verify_server_network( .into_inner(); match network_from_chain_name(&info.chain_name) { Some(actual) if actual == expected => Ok(()), + // zebra implements regtest as a configured testnet, so its + // getblockchaininfo (and hence lightwalletd's chain_name) reports + // "test" for a regtest chain. A regtest database therefore accepts a + // "test" server; the mainnet refusal (the guard that protects real + // funds) still holds, and pointing a regtest database at the public + // testnet is harmless (different receiver domain, no memos decrypt). + Some(Network::Test) if expected == Network::Regtest => Ok(()), Some(actual) => bail!( "lightwalletd is serving {} but this database is {}, refusing to \ scan the wrong chain (server chain_name={:?})", diff --git a/crates/zkv/src/shallow/decrypt.rs b/crates/zkv/src/shallow/decrypt.rs index 171d0da..02a86e1 100644 --- a/crates/zkv/src/shallow/decrypt.rs +++ b/crates/zkv/src/shallow/decrypt.rs @@ -20,40 +20,54 @@ use zcash_primitives::transaction::{components::sapling::zip212_enforcement, Tra use zcash_protocol::{ consensus::{BlockHeight, Parameters}, memo::{Memo, MemoBytes}, - ShieldedProtocol, + ShieldedPool, }; /// The database's external incoming viewing key in its single pool, with the /// per-pool trial-decryption precomputation done once. pub(crate) enum PreparedIvk { - Sapling(sapling::note_encryption::PreparedIncomingViewingKey), - Orchard(orchard::keys::PreparedIncomingViewingKey), + // Both the External and Internal scopes: a zkv write to the database's own + // address is a same-account self-send, so the memo output lands under the + // wallet's *internal* (change) IVK, not the external one (the wallet scan + // tries both scopes; shallow must too, or it detects no candidates for the + // database's own writes, including INIT). + Sapling(Vec), + Orchard(Vec), } -/// Prepare the trial-decryption key for `pool` from the UFVK. Errors if the +/// Prepare the trial-decryption keys for `pool` from the UFVK. Errors if the /// UFVK does not carry that pool's component (a parsed zkv address always /// does; its pool is inferred from which component is present). pub(crate) fn prepare_ivk( ufvk: &UnifiedFullViewingKey, - pool: ShieldedProtocol, + pool: ShieldedPool, ) -> anyhow::Result { + let scopes = [zip32::Scope::External, zip32::Scope::Internal]; match pool { - ShieldedProtocol::Sapling => { + ShieldedPool::Sapling => { let dfvk = ufvk .sapling() .ok_or_else(|| anyhow!("UFVK has no Sapling component"))?; - let ivk = dfvk.to_ivk(zip32::Scope::External); Ok(PreparedIvk::Sapling( - sapling::note_encryption::PreparedIncomingViewingKey::new(&ivk), + scopes + .iter() + .map(|s| { + sapling::note_encryption::PreparedIncomingViewingKey::new(&dfvk.to_ivk(*s)) + }) + .collect(), )) } - ShieldedProtocol::Orchard => { + // Ironwood shares the Orchard receiver, so it trial-decrypts with the + // Orchard IVK. + ShieldedPool::Orchard | ShieldedPool::Ironwood => { let fvk = ufvk .orchard() .ok_or_else(|| anyhow!("UFVK has no Orchard component"))?; - let ivk = fvk.to_ivk(zip32::Scope::External); Ok(PreparedIvk::Orchard( - orchard::keys::PreparedIncomingViewingKey::new(&ivk), + scopes + .iter() + .map(|s| orchard::keys::PreparedIncomingViewingKey::new(&fvk.to_ivk(*s))) + .collect(), )) } } @@ -75,27 +89,53 @@ pub(crate) fn scan_compact_block( continue; }; let matched = match ivk { - PreparedIvk::Sapling(pivk) => { + PreparedIvk::Sapling(pivks) => { let domain = sapling::note_encryption::SaplingDomain::new(zip212_enforcement( params, height, )); vtx.outputs.iter().any(|out| { - sapling::note_encryption::CompactOutputDescription::try_from(out) - .ok() - .and_then(|desc| try_compact_note_decryption(&domain, pivk, &desc)) - .is_some() + let Ok(desc) = + sapling::note_encryption::CompactOutputDescription::try_from(out) + else { + return false; + }; + pivks + .iter() + .any(|pivk| try_compact_note_decryption(&domain, pivk, &desc).is_some()) }) } - PreparedIvk::Orchard(pivk) => vtx.actions.iter().any(|act| { - orchard::note_encryption::CompactAction::try_from(act) - .ok() - .and_then(|action| { - let domain = - orchard::note_encryption::OrchardDomain::for_compact_action(&action); - try_compact_note_decryption(&domain, pivk, &action) - }) - .is_some() - }), + PreparedIvk::Orchard(pivks) => { + // Orchard (V5) and Ironwood (V6) actions live in *separate* + // compact fields: `CompactTx.actions` carries V5 Orchard actions + // (V2 note plaintexts, `OrchardDomain`), while `CompactTx + // .ironwood_actions` carries V6 Ironwood actions (V3 note + // plaintexts, `IronwoodDomain`). They share the Orchard receiver + // and IVK and the `CompactOrchardAction` shape, only the field + // and note-plaintext version differ. This build's own writes on + // an Ironwood chain land in `ironwood_actions`, so scanning only + // `actions` (the earlier bug) finds nothing. + let orchard_hit = vtx.actions.iter().any(|act| { + let Ok(action) = orchard::note_encryption::CompactAction::try_from(act) else { + return false; + }; + let domain = + orchard::note_encryption::OrchardDomain::for_compact_action(&action); + pivks + .iter() + .any(|pivk| try_compact_note_decryption(&domain, pivk, &action).is_some()) + }); + let ironwood_hit = vtx.ironwood_actions.iter().any(|act| { + let Ok(action) = orchard::note_encryption::CompactAction::try_from(act) else { + return false; + }; + let domain = + orchard::note_encryption::IronwoodDomain::for_compact_action(&action); + pivks + .iter() + .any(|pivk| try_compact_note_decryption(&domain, pivk, &action).is_some()) + }); + orchard_hit || ironwood_hit + } }; if matched { hits.push(TxId::from_bytes(txid_bytes)); @@ -113,7 +153,7 @@ pub(crate) fn scan_compact_block( /// memos in one transaction; iterating all decrypted outputs handles that. pub(crate) fn extract_memos( params: &P, - pool: ShieldedProtocol, + pool: ShieldedPool, ufvk: &UnifiedFullViewingKey, tx: &Transaction, mined_height: Option, @@ -123,15 +163,25 @@ pub(crate) fn extract_memos( let decrypted = decrypt_transaction::<_, u32>(params, mined_height, Some(chain_tip), tx, &ufvks); let outputs: Vec<(usize, &MemoBytes)> = match pool { - ShieldedProtocol::Sapling => decrypted + ShieldedPool::Sapling => decrypted .sapling_outputs() .iter() .map(|o: &DecryptedOutput| (o.index(), o.memo())) .collect(), - ShieldedProtocol::Orchard => decrypted + // Ironwood shares the Orchard receiver but is a distinct value pool with + // its own decrypted-output accessor (`decrypt_transaction` splits V2 + // Orchard from V3 Ironwood notes). A V6 write lands in `ironwood_outputs`, + // so both must be scanned or this build's own memos on an Ironwood chain + // vanish. The element type is identical, so they chain directly. + ShieldedPool::Orchard | ShieldedPool::Ironwood => decrypted .orchard_outputs() .iter() - .map(|o: &DecryptedOutput| (o.index(), o.memo())) + .chain(decrypted.ironwood_outputs()) + .map( + |o: &DecryptedOutput<(orchard::Note, orchard::ValuePool), u32>| { + (o.index(), o.memo()) + }, + ) .collect(), }; outputs diff --git a/crates/zkv/src/shallow/mod.rs b/crates/zkv/src/shallow/mod.rs index c0c1b29..3a362ee 100644 --- a/crates/zkv/src/shallow/mod.rs +++ b/crates/zkv/src/shallow/mod.rs @@ -66,7 +66,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use zcash_primitives::transaction::TxId; -use zcash_protocol::{consensus, ShieldedProtocol}; +use zcash_protocol::ShieldedPool; use crate::{ config::WalletConfig, @@ -217,8 +217,8 @@ pub enum ShallowError { /// [`GrpcSource`]) so the drivers are testable against an in-memory chain; /// the public constructors always build the gRPC form. pub struct ShallowClient { - network: consensus::Network, - pool: ShieldedProtocol, + network: crate::network::Network, + pool: ShieldedPool, birthday: u32, /// The signing-domain receiver every `ZKV0` signature binds to. receiver: String, @@ -298,8 +298,8 @@ impl ShallowClient { cache_dir: Option, ) -> Self { Self { - network: consensus::Network::TestNetwork, - pool: ShieldedProtocol::Orchard, + network: crate::network::Network::Test, + pool: ShieldedPool::Orchard, birthday, receiver, root_hex, @@ -315,11 +315,11 @@ impl ShallowClient { &self.zkv_addr } - pub fn network(&self) -> consensus::Network { + pub fn network(&self) -> crate::network::Network { self.network } - pub fn pool(&self) -> ShieldedProtocol { + pub fn pool(&self) -> ShieldedPool { self.pool } diff --git a/crates/zkv/src/shallow/source.rs b/crates/zkv/src/shallow/source.rs index 2ffbd31..70320e0 100644 --- a/crates/zkv/src/shallow/source.rs +++ b/crates/zkv/src/shallow/source.rs @@ -16,10 +16,7 @@ use zcash_client_backend::proto::service::{ }; use zcash_keys::keys::UnifiedFullViewingKey; use zcash_primitives::transaction::TxId; -use zcash_protocol::{ - consensus::{self, BlockHeight}, - ShieldedProtocol, -}; +use zcash_protocol::{consensus::BlockHeight, ShieldedPool}; use super::{decrypt, validate, ShallowError, CHUNK}; use crate::internal::sync; @@ -57,8 +54,8 @@ pub trait ChainSource { /// with the database's viewing key. pub struct GrpcSource { pub(crate) client: CompactTxStreamerClient, - pub(crate) network: consensus::Network, - pub(crate) pool: ShieldedProtocol, + pub(crate) network: crate::network::Network, + pub(crate) pool: ShieldedPool, pub(crate) ufvk: UnifiedFullViewingKey, pub(crate) ivk: decrypt::PreparedIvk, } diff --git a/crates/zkv/src/shallow/testutil.rs b/crates/zkv/src/shallow/testutil.rs index e2340f4..7c3ec56 100644 --- a/crates/zkv/src/shallow/testutil.rs +++ b/crates/zkv/src/shallow/testutil.rs @@ -10,8 +10,8 @@ use std::rc::Rc; use transparent::keys::NonHardenedChildIndex; use zcash_keys::keys::UnifiedSpendingKey; use zcash_primitives::transaction::TxId; -use zcash_protocol::consensus::{self, NetworkType}; -use zcash_protocol::ShieldedProtocol; +use zcash_protocol::consensus::NetworkType; +use zcash_protocol::ShieldedPool; use super::{ChainSource, ShallowError}; use crate::protocol::{ @@ -22,12 +22,12 @@ use crate::protocol::{ /// A test database identity: the receiver domain, the root `zkvid1…`, and /// the root signing key (the same derivation `internal::account` uses). pub(crate) fn fixture() -> (String, String, secp256k1::SecretKey) { - let net = consensus::Network::TestNetwork; + let net = crate::network::Network::Test; let usk = UnifiedSpendingKey::from_seed(&net, &[0x42; 32], zip32::AccountId::ZERO) .expect("derive USK"); let ufvk = usk.to_unified_full_viewing_key(); - let receiver = receiver_domain(&ufvk, ShieldedProtocol::Orchard, NetworkType::Test) - .expect("receiver domain"); + let receiver = + receiver_domain(&ufvk, ShieldedPool::Orchard, NetworkType::Test).expect("receiver domain"); let root_hex = pubkey_bech32(&zkv_verifying_pubkey(&ufvk).expect("root pubkey")); let idx = NonHardenedChildIndex::from_index(ZKV_TRANSPARENT_INDEX).expect("index"); let sk = usk diff --git a/deny.toml b/deny.toml index 817f1f6..d745a01 100644 --- a/deny.toml +++ b/deny.toml @@ -51,4 +51,16 @@ wildcards = "deny" [sources] unknown-registry = "deny" unknown-git = "deny" -allow-git = [] +# Ironwood (NU6.3) support pins the librustzcash crate set to the zecrocks fork's +# ironwood-scan-model branch via [patch.crates-io] in the workspace Cargo.toml +# (the crates.io releases of the wallet crates depend on the `-pre` lower crates +# and so can't be used directly), and that branch in turn needs an unreleased +# shardtree/incrementalmerkletree. Allow those two git sources until the NU6.3 +# crates are published to crates.io, at which point the patches and these entries +# come back out. See the workspace Cargo.toml comment. +allow-git = [ + "https://github.com/zecrocks/librustzcash", + # zecrocks fork of zcash/incrementalmerkletree: upstream decefc46 plus the + # shardtree clear_flags checkpoint-pruning panic fix (see workspace Cargo.toml). + "https://github.com/zecrocks/incrementalmerkletree", +] diff --git a/regtest-harness/.gitignore b/regtest-harness/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/regtest-harness/.gitignore @@ -0,0 +1 @@ +/target diff --git a/regtest-harness/Cargo.lock b/regtest-harness/Cargo.lock new file mode 100644 index 0000000..fe36031 --- /dev/null +++ b/regtest-harness/Cargo.lock @@ -0,0 +1,944 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zkv-regtest-harness" +version = "0.0.0" +dependencies = [ + "anyhow", + "reqwest", + "serde_json", + "tempfile", + "tokio", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/regtest-harness/Cargo.toml b/regtest-harness/Cargo.toml new file mode 100644 index 0000000..55dc00a --- /dev/null +++ b/regtest-harness/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "zkv-regtest-harness" +version = "0.0.0" +edition = "2021" +publish = false +description = "End-to-end regtest integration harness for zkv (zebra + lightwalletd, Zcash-Foundation-standard)" + +# Standalone workspace on purpose (mirroring zecd's regtest harness): keeps the +# harness's e2e-only dependency tree (reqwest etc.) fully isolated from zkv's +# hard-pinned librustzcash lockfile; `cargo build` in the parent never sees +# these deps. A committed Cargo.lock pins the tree; build with --locked. +# +# There is NO zebra crate dependency: blocks are mined via zebrad's +# Regtest-only `generate` RPC (zebra >= 2.0.0), so the harness is a pure +# black-box driver and works against any zebrad release. zkv itself is driven +# as a subprocess (the real CLI binary), so the harness exercises the real +# sync/write/broadcast pipeline, not library shortcuts. +[workspace] + +[dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "time", "fs", "io-util", "net"] } +# Plaintext HTTP only (zebrad's JSON-RPC is local http://) - no TLS stack pulled in. +reqwest = { version = "0.12", default-features = false, features = ["json"] } +serde_json = "1" +anyhow = "1" +tempfile = "3" diff --git a/regtest-harness/README.md b/regtest-harness/README.md new file mode 100644 index 0000000..cd1a418 --- /dev/null +++ b/regtest-harness/README.md @@ -0,0 +1,105 @@ +# zkv regtest end-to-end harness + +A standalone crate that runs the real `zkv` CLI binary against a **real** +Zcash backend, `zebra` in **Regtest** mode behind `lightwalletd`, and drives +it through the full database lifecycle. It is the "live" integration tier; +the offline tier lives in `cargo test --workspace` at the repo root (the +protocol/replay/snapshot/shallow unit tests). + +The stack and the process-orchestration code follow zecd's regtest harness +(the Zcash-Foundation-standard approach): there is **no +`zingo-infra`/`zcash_local_net` dependency** and **no compile-time zebra +dependency**. Blocks are mined with zebrad's own **Regtest-only `generate` +RPC** (shipped since zebra 2.0.0), which runs the `getblocktemplate` -> +assemble -> `submitblock` flow *server-side* against the node's own network +parameters (PoW is disabled on Regtest, so there is no solving step). The +harness is a pure black-box driver: it works unmodified against any zebrad +release and drives `zkv` strictly as subprocesses of the built binary, so the +real sync/sign/broadcast pipeline is exercised, not library shortcuts. + +## Why a separate crate + +The harness lives in its **own workspace** (note the empty `[workspace]` in +`Cargo.toml`), so its e2e-only dependency tree (reqwest etc.) never touches +zkv's hard-pinned librustzcash lockfile: `cargo build` at the repo root never +sees it. It commits its **own `Cargo.lock`**; build with `--locked`. + +## What runs + +- `tests/regtest_unfunded.rs`: the unfunded surface. Create a regtest + database against a live chain (`zkv init --network regtest + --non-interactive`), the self-describing `zkvregtest1...` address (offline + `inspect` recovers network/pool/birthday/keys), the not-initialized read + refusal, `list`, and the non-interactive resume path. Needs only + zebrad + lightwalletd. +- `tests/regtest_kv.rs`: the funded lifecycle, the load-bearing protocol e2e: + 1. Fund a `zcash-devtool` wallet by mining its transparent coinbase, mature + it (100 blocks, via a miner-swap restart), shield to Orchard. + 2. `zkv init` (create), fund the wallet's UA from the devtool wallet, then + the `zkv init` resume path broadcasts INIT and waits for confirmation + while the harness mines. + 3. Data ops on chain: SET (create), SET (overwrite; the second write must + carry a nonzero replay-protection `[seq]` on the wire), a second key, + DEL (tombstone), `keys` globbing. + 4. `history`: the genesis INIT entry, both greeting writes, the DEL, every + entry signature-verified, creator attribution. + 5. Roles: WRITERADD/WRITERDEL management memos targeting a second + database's `zkvid1...` key; the registry and the revocation tombstone. + 6. A watch-only replica imported from nothing but the `zkvregtest1...` + address converges on the same state; a duplicate import is refused. + 7. A shallow (db-less) `zkv shallow get` against the bare address agrees + with the full replay. + +Together these cover the paths with no offline tests at all (`sync.rs`, +`write.rs`, `send.rs`, the command modules) plus the on-chain halves of the +protocol invariants the unit tests can only simulate. + +## Funding Orchard on regtest + +Regtest can't mine a coinbase straight into an Orchard note that a shielded +wallet would scan, so the funded test funds zkv the way the protocol allows, +using [`zcash-devtool`](https://github.com/zecrocks/zcash-devtool) +(regtest-enabled) as a funding wallet (`$DEVTOOL_BIN`): + +1. Mine a **transparent** coinbase to the funder's address (zebra's + `[mining] miner_address`). +2. Mine past **coinbase maturity** (100 blocks). +3. `devtool wallet shield`: shield the matured coinbase into **Orchard**. +4. `devtool wallet send`: send TAZ to the zkv wallet's `uregtest1...` funding + UA; mine to spendability (external receives confirm at the untrusted + ZIP-315 depth, 10 blocks). + +## Running + +Provide the node binaries via `$ZEBRAD_BIN` / `$LIGHTWALLETD_BIN` (any zebrad +>= 2.2.0 works; in CI they're extracted from the `zfnd/zebra` and +`electriccoinco/lightwalletd` images) and the funder via `$DEVTOOL_BIN`. +Without them the tests **skip**, so they still validate that the harness +compiles and links. + +```sh +# From the repo root: the harness drives the release binary (debug Orchard +# proving is >20s per write). +cargo build --release -p zkv --bin zkv --no-default-features --features cli,transparent-inputs + +# Compile + link; skips the live run unless the binaries are provided: +cargo test --locked --manifest-path regtest-harness/Cargo.toml -- --nocapture --test-threads=1 + +# Full live run: +ZKV_BIN=$PWD/target/release/zkv \ +ZEBRAD_BIN=/path/to/zebrad LIGHTWALLETD_BIN=/path/to/lightwalletd \ +DEVTOOL_BIN=/path/to/zcash-devtool \ + cargo test --locked --manifest-path regtest-harness/Cargo.toml -- --nocapture --test-threads=1 +``` + +Debug hooks: `ZEBRAD_STDERR=` captures zebrad's logs; the `zkv init` +poll loop's status lines stream to the test output (use `--nocapture`). + +The regtest chain's activation heights (NU5/NU6 at height 1, NU6.1/NU6.2 at +height 4) are written into `zebrad.toml` by the harness and **must match** +`zkv`'s fixed regtest parameters in `crates/zkv/src/network.rs`; change them +together or signatures/branch ids diverge. + +Bumping zebra: change the `zfnd/zebra` image tag in +`.github/workflows/regtest.yml`; that's it. The weekly CI cron tests both the +pinned image and `zfnd/zebra:latest` (the upstream canary). diff --git a/regtest-harness/src/lib.rs b/regtest-harness/src/lib.rs new file mode 100644 index 0000000..f267e06 --- /dev/null +++ b/regtest-harness/src/lib.rs @@ -0,0 +1,1019 @@ +//! End-to-end regtest harness for `zkv`. +//! +//! Orchestrates a `zebrad` (Regtest, PoW disabled) node with a `lightwalletd` +//! in front of it, and drives the real `zkv` CLI binary as a subprocess +//! against that stack, so the whole pipeline (lightwalletd gRPC sync, memo +//! build/sign, ZIP-317 spend, broadcast, snapshot + tail replay) is exercised +//! exactly as a user runs it. 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 parameters; the +//! harness has no zebra crate dependency and works against any zebrad release. +//! +//! Funding: regtest can't mine a coinbase straight into an Orchard note, so +//! the funded test mines a transparent coinbase to a `zcash-devtool` funding +//! wallet, lets it mature, shields it into Orchard, and sends TAZ to the zkv +//! wallet's funding address. +//! +//! Binaries are supplied by the caller via `$ZEBRAD_BIN` / `$LIGHTWALLETD_BIN` +//! / `$DEVTOOL_BIN` (see [`resolve_bin`]); in CI they are extracted from the +//! official `zfnd/zebra` and `electriccoinco/lightwalletd` images. `zkv` +//! itself is the built release binary (`$ZKV_BIN`, defaulting to the parent +//! workspace's `target/release/zkv`). +//! +//! The process-orchestration layer (zebrad/lightwalletd/funder drivers) is +//! adapted from zecd's regtest harness, which pioneered this +//! Zcash-Foundation-standard stack (zebra `generate` RPC, no zingo-infra). + +use std::io::Write; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{json, Value}; + +/// Pick an unused loopback TCP port (bind `:0`, read the port, release it). +/// Racy by nature, but fine for a single-threaded test run. +pub fn pick_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0").context("bind ephemeral port")?; + Ok(listener.local_addr()?.port()) +} + +/// Resolve a required external binary from `$`, returning `None` if +/// unset or missing so callers can skip the live test cleanly. +pub fn resolve_bin(env_var: &str) -> Option { + std::env::var(env_var) + .ok() + .map(PathBuf::from) + .filter(|p| p.is_file()) +} + +// =============================== zebrad (Regtest validator) =============================== + +/// Height at which NU6.1 and NU6.2 activate on our regtest chain. NU5/NU6 are +/// active from height 1; NU6.1's activation block requires ZIP-271 lockbox +/// disbursements out of the deferred pool, which only accrues once NU6 is +/// live, so NU6.1/NU6.2 activate a few blocks in, after a pool exists. +/// Must match `zkv`'s `network::Network::Regtest` activation heights +/// (`crates/zkv/src/network.rs`). +const NU6_2_ACTIVATION_HEIGHT: u32 = 4; +/// Height at which NU6.3 (Ironwood) activates on our regtest chain, emitted into +/// zebrad's config only for the Ironwood tier (see [`nu6_3_height`]). **Must** +/// match `zkv`'s `network::REGTEST_NU6_3_HEIGHT` and the devtool funder's +/// `--activation-heights`, or NU6.3 consensus diverges. A few blocks after +/// NU6.2, mirroring how NU6.2 trails NU6. +const NU6_3_ACTIVATION_HEIGHT: u32 = 8; + +/// The NU6.3 (Ironwood) regtest activation height, or `None` for a pre-Ironwood +/// (stock zebra) run. Enabled by setting `ZKV_REGTEST_NU6_3=1` (the CI Ironwood +/// tier does this). Opt-in because stock zebra rejects the unknown `"NU6.3"` +/// activation-height key at startup, so it can only be emitted against an +/// Ironwood-capable zebra (e.g. `zfnd/zebra:6.0.0-rc.0`). An Ironwood zkv build +/// always activates NU6.3 at this height on regtest, so it agrees with the chain +/// only when this is enabled. +fn nu6_3_height() -> Option { + match std::env::var("ZKV_REGTEST_NU6_3") { + Ok(v) if !v.is_empty() && v != "0" => Some(NU6_3_ACTIVATION_HEIGHT), + _ => None, + } +} +/// 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"; +const LOCKBOX_DISBURSEMENT_ZATS: u64 = 1; + +/// A throwaway transparent address used as zebra's coinbase recipient when the +/// caller doesn't need to control the coinbase (the unfunded e2e). Funded +/// flows pass the funding wallet's own address. +const DEFAULT_MINER_ADDRESS: &str = "t27eWDgjFYJGVXmzrXeVjnb5J3uXDM9xH9v"; + +/// A running `zebrad` Regtest node. +pub struct Zebrad { + child: Child, + /// JSON-RPC port (cookie auth disabled so lightwalletd can connect). + pub rpc_port: u16, + net_port: u16, + bin: PathBuf, + config_path: PathBuf, + _dir: tempfile::TempDir, +} + +/// Spawn `zebrad --config start`. Set ZEBRAD_STDERR to a file +/// path to capture its logs (zebra logs to stdout, so route both there); +/// otherwise discard them to keep test output clean. +fn spawn_zebrad(bin: &Path, config_path: &Path) -> Result { + let (out, err) = match std::env::var_os("ZEBRAD_STDERR") { + Some(p) => { + let f = std::fs::File::create(&p).context("create ZEBRAD_STDERR file")?; + let f2 = f.try_clone().context("clone ZEBRAD_STDERR file")?; + (Stdio::from(f), Stdio::from(f2)) + } + None => (Stdio::null(), Stdio::null()), + }; + let mut cmd = Command::new(bin); + // zebrad reads `ZEBRA_*` environment variables as config overrides + // (config-rs), and an unrelated variable like `ZEBRA_TAG` in a CI job + // makes it exit at startup with "Configuration error: unknown field". + // Scrub the prefix so the harness only ever configures zebrad through the + // config file it writes. + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("ZEBRA_") { + cmd.env_remove(key); + } + } + cmd.args(["--config", config_path.to_str().unwrap(), "start"]) + .stdout(out) + .stderr(err) + .spawn() + .with_context(|| format!("spawn zebrad ({})", bin.display())) +} + +impl Zebrad { + /// Launch `zebrad` in Regtest mode (mining to a throwaway address) and + /// wait until its JSON-RPC answers. + pub async fn start(bin: &Path) -> Result { + Self::start_with_miner(bin, DEFAULT_MINER_ADDRESS).await + } + + /// Launch `zebrad` mining its coinbase to `miner_address`, so a wallet + /// that controls that address can spend the matured coinbase (used to + /// fund the wallet under test). + pub async fn start_with_miner(bin: &Path, miner_address: &str) -> Result { + let dir = tempfile::tempdir().context("create zebrad dir")?; + let rpc_port = pick_port()?; + let net_port = pick_port()?; + let config_path = dir.path().join("zebrad.toml"); + let cache_dir = dir.path().join("state"); + std::fs::write( + &config_path, + zebrad_toml( + net_port, + rpc_port, + miner_address, + &cache_dir.to_string_lossy(), + ), + ) + .context("write zebrad.toml")?; + let child = spawn_zebrad(bin, &config_path)?; + let mut zebrad = Zebrad { + child, + rpc_port, + net_port, + bin: bin.to_path_buf(), + config_path, + _dir: dir, + }; + zebrad.wait_until_rpc_up().await?; + Ok(zebrad) + } + + /// Restart `zebrad` mining to a different address, preserving the chain + /// (persistent state). Used by the funded e2e to stop minting coinbases to + /// the funder so its existing coinbases can age past maturity while a + /// throwaway address mines the tail. + pub async fn restart_with_miner(&mut self, miner_address: &str) -> Result<()> { + // Clean shutdown via the regtest `stop` RPC (raises SIGINT) so zebra + // backs up its non-finalized state. A SIGKILL would drop the recent, + // not-yet-finalized blocks and reset the chain to genesis, losing the + // funder's coinbases. + let _ = self.rpc("stop", json!([])).await; + let deadline = Instant::now() + Duration::from_secs(60); + loop { + match self.child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(200)).await; + } + _ => { + let _ = self.child.kill(); + let _ = self.child.wait(); + break; + } + } + } + let cache_dir = self._dir.path().join("state"); + std::fs::write( + &self.config_path, + zebrad_toml( + self.net_port, + self.rpc_port, + miner_address, + &cache_dir.to_string_lossy(), + ), + ) + .context("rewrite zebrad.toml for restart")?; + self.child = spawn_zebrad(&self.bin, &self.config_path)?; + self.wait_until_rpc_up().await?; + Ok(()) + } + + fn rpc_url(&self) -> String { + format!("http://127.0.0.1:{}/", self.rpc_port) + } + + async fn wait_until_rpc_up(&mut self) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(120); + let mut last_err = anyhow!("no getblocktemplate attempt completed"); + loop { + // A dead zebrad can never become mineable; fail immediately with + // the exit status instead of burning the whole timeout on + // connection-refused. + if let Ok(Some(status)) = self.child.try_wait() { + bail!( + "zebrad exited during startup ({status}); \ + set ZEBRAD_STDERR= to capture its logs" + ); + } + // `getblocktemplate` succeeds only once zebra's RPC is up *and* it + // considers itself synced to the chain tip (mempool active), which + // is exactly the precondition for `generate_blocks`. On a fresh + // node this readiness lags RPC availability by a moment, so poll + // the template endpoint itself rather than `getblockchaininfo`. + match zebra_rpc_call(&self.rpc_url(), "getblocktemplate", json!([])).await { + Ok(_) => return Ok(()), + Err(e) => last_err = e, + } + if Instant::now() >= deadline { + bail!("zebrad did not become mineable within 120s; last error: {last_err:#}"); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + + /// Issue a raw JSON-RPC call to this zebrad (test/diagnostic helper). + pub async fn rpc(&self, method: &str, params: Value) -> Result { + zebra_rpc_call(&self.rpc_url(), method, params).await + } + + /// 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 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. + pub async fn generate_blocks(&self, n: u32) -> Result<()> { + let hashes = zebra_rpc_call(&self.rpc_url(), "generate", json!([n])) + .await + .context("generate")?; + // `generate` returns the array of mined block hashes; a short array + // means some block was rejected. Fail loudly so the chain can't + // silently stop advancing. + let mined = hashes.as_array().map(|a| a.len()).unwrap_or(0); + if mined != n as usize { + bail!("generate mined {mined} of {n} requested blocks: {hashes}"); + } + Ok(()) + } + + /// The current best-chain tip height as zebra reports it. + pub async fn tip_height(&self) -> Result { + let info = self.rpc("getblockchaininfo", json!([])).await?; + info.get("blocks") + .and_then(|b| b.as_u64()) + .ok_or_else(|| anyhow!("getblockchaininfo.blocks missing: {info}")) + } +} + +impl Drop for Zebrad { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// zebrad Regtest config. `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`. The activation heights must +/// match zkv's `network::Network::Regtest`. +fn zebrad_toml(net_port: u16, rpc_port: u16, miner_address: &str, cache_dir: &str) -> String { + let nu6_2 = NU6_2_ACTIVATION_HEIGHT; + let lockbox_addr = LOCKBOX_DISBURSEMENT_ADDR; + let lockbox_amount = LOCKBOX_DISBURSEMENT_ZATS; + // Ironwood tier only: stock zebra rejects the unknown "NU6.3" key at startup, + // so emit it solely when running against an Ironwood-capable zebra. + let nu6_3_line = match nu6_3_height() { + Some(h) => format!("\n\"NU6.3\" = {h}"), + None => String::new(), + }; + format!( + r#"[network] +network = "Regtest" +listen_addr = "127.0.0.1:{net_port}" + +[network.testnet_parameters] +disable_pow = true + +# NU5/NU6 from genesis, then NU6.1+NU6.2 at NU6_2_ACTIVATION_HEIGHT. NU6.1 +# can't activate at height 1: its activation block must carry ZIP-271 one-time +# lockbox disbursements, and the deferred (lockbox) pool only accrues once NU6 +# is active, so we let NU6 run for a few blocks to build a pool, then disburse +# a token amount at the NU6.1/NU6.2 activation block. zebra's getblocktemplate +# emits the disbursement output automatically from the config below. +# zkv's regtest network (crates/zkv/src/network.rs) must match these heights. +[network.testnet_parameters.activation_heights] +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] +start = 1 +end = 1_000_000 +[[network.testnet_parameters.funding_streams.recipients]] +receiver = "Deferred" +numerator = 12 +addresses = [] + +# The ZIP-271 one-time disbursement paid at the NU6.1 activation block. The +# amount need only be <= the pool accrued by then; the residual stays in the +# lockbox. +[[network.testnet_parameters.lockbox_disbursements]] +address = "{lockbox_addr}" +amount = {lockbox_amount} + +[mining] +miner_address = "{miner_address}" + +[state] +# Persistent (not ephemeral) so the chain survives a restart with a different +# miner address: the funded e2e mines the funder's coinbases, then restarts +# mining to a throwaway address to age them past coinbase maturity (see +# Zebrad::restart_with_miner). +ephemeral = false +cache_dir = "{cache_dir}" + +[rpc] +listen_addr = "127.0.0.1:{rpc_port}" +enable_cookie_auth = false +"# + ) +} + +// =============================== lightwalletd (indexer) =============================== + +/// A running `lightwalletd` pointed at a regtest zebrad. zkv speaks +/// lightwalletd's gRPC (`CompactTxStreamer`), so every zkv command in the +/// harness goes through this. +pub struct Lightwalletd { + child: Child, + /// gRPC port serving the lightwalletd `CompactTxStreamer` protocol. + pub grpc_port: u16, + _dir: tempfile::TempDir, +} + +impl Lightwalletd { + /// Launch `lightwalletd` against the given zebrad RPC port and wait until + /// its gRPC server is up. + pub async fn start(bin: &Path, zebrad_rpc_port: u16) -> Result { + let grpc_port = pick_port()?; + let dir = tempfile::tempdir().context("create lightwalletd dir")?; + let http_port = pick_port()?; + let data_dir = dir.path().join("data"); + std::fs::create_dir_all(&data_dir)?; + + // lightwalletd reads the node's RPC connection details from a + // zcash.conf-style file. + let zcash_conf = dir.path().join("zcash.conf"); + std::fs::write( + &zcash_conf, + format!( + "rpcuser=zkvtest\nrpcpassword=zkvtest\nrpcbind=127.0.0.1\nrpcport={zebrad_rpc_port}\n" + ), + ) + .context("write zcash.conf")?; + + let log_file = dir.path().join("lightwalletd.log"); + let child = Command::new(bin) + .args([ + "--no-tls-very-insecure", + "--grpc-bind-addr", + &format!("127.0.0.1:{grpc_port}"), + "--http-bind-addr", + &format!("127.0.0.1:{http_port}"), + "--data-dir", + data_dir.to_str().unwrap(), + "--log-file", + log_file.to_str().unwrap(), + "--zcash-conf-path", + zcash_conf.to_str().unwrap(), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("spawn lightwalletd ({})", bin.display()))?; + + let lwd = Lightwalletd { + child, + grpc_port, + _dir: dir, + }; + lwd.wait_until_ready(&log_file).await?; + Ok(lwd) + } + + async fn wait_until_ready(&self, log_file: &Path) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(90); + loop { + if let Ok(log) = std::fs::read_to_string(log_file) { + if log.contains("Starting insecure no-TLS (plaintext) server") { + return Ok(()); + } + } + if Instant::now() >= deadline { + let log = std::fs::read_to_string(log_file).unwrap_or_default(); + bail!( + "lightwalletd did not become ready within 90s; log tail:\n{}", + tail(&log, 20) + ); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } +} + +impl Drop for Lightwalletd { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +// =============================== funder (zcash-devtool) =============================== + +/// A valid 24-word BIP-39 test mnemonic (the canonical all-zero-entropy +/// vector). Regtest only: it controls throwaway coinbase funds, never +/// anything of value. +const FUNDER_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ +abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ +abandon abandon abandon art"; + +/// Drives the `zcash-devtool` binary as a funding wallet. It controls zebra's +/// coinbase (which is mined to its address), shields the matured transparent +/// coinbase into Orchard, and sends Orchard TAZ to the zkv wallet's funding +/// address. Resolve the binary via `$DEVTOOL_BIN`. +/// +/// Regtest can't mine a coinbase straight into an Orchard note, so this is +/// how funds reach zkv: mine transparent coinbase -> mature (100 blocks) -> +/// shield to Orchard -> send to zkv's funding UA. +pub struct Funder { + bin: PathBuf, + dir: tempfile::TempDir, +} + +impl Funder { + /// Derive the funder's default transparent address offline (no chain, no + /// wallet) from its fixed mnemonic, so zebra can be told to mine its + /// coinbase here *before* any chain exists. Mining straight to the funder + /// keeps everything on one chain, so the wallet's birthday anchor stays + /// valid (a throwaway chain would hand the wallet a wrong note-commitment + /// anchor). + pub fn derive_transparent_address(bin: &Path) -> Result { + let output = Command::new(bin) + .args([ + "wallet", + "derive-address", + "--network", + "regtest", + "--mnemonic", + FUNDER_MNEMONIC, + ]) + .output() + .context("spawn devtool derive-address")?; + if !output.status.success() { + bail!( + "devtool derive-address failed ({}): {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + let out = String::from_utf8_lossy(&output.stdout); + out.lines() + .find_map(|line| line.split("Transparent Address:").nth(1)) + .map(|addr| addr.trim().to_string()) + .ok_or_else(|| anyhow!("no Transparent Address in derive-address output:\n{out}")) + } + + /// Initialise the funding wallet against a lightwalletd. `--birthday 2` is + /// the lowest height with a tree state (init fetches `GetTreeState(birthday-1)`, + /// 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 { + let dir = tempfile::tempdir().context("create funder dir")?; + let funder = Funder { + bin: bin.to_path_buf(), + dir, + }; + let identity = funder.identity(); + let mut args: Vec = vec![ + "--name".into(), + "funder".into(), + "--network".into(), + "regtest".into(), + "--identity".into(), + identity, + "--birthday".into(), + "2".into(), + ]; + // Two devtool generations differ in how `wallet init` takes the seed and + // the regtest activation heights, so gate both on the Ironwood-tier + // marker (see `nu6_3_height`): + // - Ironwood devtool: `wallet init` no longer accepts `--mnemonic`; it + // reads the phrase from stdin when stdin is not a terminal. It also + // *requires* `--activation-heights` for `-n regtest` (rejecting it + // otherwise); the heights must match the zebrad config and zkv's + // `network::Network::Regtest` exactly, or NU6.3 consensus diverges. + // - Stock (pre-Ironwood) devtool: takes `--mnemonic` and has no + // `--activation-heights` flag. + let stdin = if nu6_3_height().is_some() { + let path = funder.write_activation_heights()?; + args.push("--activation-heights".into()); + args.push(path); + Some(format!("{FUNDER_MNEMONIC}\n")) + } else { + args.push("--mnemonic".into()); + args.push(FUNDER_MNEMONIC.into()); + None + }; + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + funder.run_with_stdin("init", &arg_refs, Some(lwd_port), stdin.as_deref())?; + Ok(funder) + } + + /// Write the regtest activation-heights TOML the Ironwood devtool's + /// `wallet init --activation-heights ` consumes (the + /// `ActivationHeights` schema: one optional height per upgrade). Must match + /// the zebrad config ([`zebrad_toml`]) and zkv's `network::Network::Regtest` + /// heights, or the funder's transactions carry the wrong consensus branch id. + fn write_activation_heights(&self) -> Result { + let path = self.dir.path().join("activation-heights.toml"); + let nu6_2 = NU6_2_ACTIVATION_HEIGHT; + let nu6_3 = NU6_3_ACTIVATION_HEIGHT; + let toml = format!( + "overwinter = 1\nsapling = 1\nblossom = 1\nheartwood = 1\ncanopy = 1\n\ + nu5 = 1\nnu6 = 1\nnu6_1 = {nu6_2}\nnu6_2 = {nu6_2}\nnu6_3 = {nu6_3}\n" + ); + std::fs::write(&path, toml).context("write activation-heights.toml")?; + Ok(path.to_string_lossy().into_owned()) + } + + fn identity(&self) -> String { + self.dir + .path() + .join("identity.txt") + .to_string_lossy() + .into_owned() + } + + fn wallet_dir(&self) -> String { + self.dir.path().to_string_lossy().into_owned() + } + + /// Scan the chain via lightwalletd to pick up new transactions / UTXOs. + pub fn sync(&self, lwd_port: u16) -> Result<()> { + self.run("sync", &[], Some(lwd_port)).map(|_| ()) + } + + /// Shield all spendable transparent funds (the matured coinbase) into Orchard. + pub fn shield(&self, lwd_port: u16) -> Result<()> { + let identity = self.identity(); + self.run("shield", &["--identity", &identity], Some(lwd_port)) + .map(|_| ()) + } + + /// Send `zatoshis` to `to_address` (a shielded/unified address). + pub fn send(&self, lwd_port: u16, to_address: &str, zatoshis: u64) -> Result<()> { + let identity = self.identity(); + let value = zatoshis.to_string(); + self.run( + "send", + &[ + "--identity", + &identity, + "--address", + to_address, + "--value", + &value, + ], + Some(lwd_port), + ) + .map(|_| ()) + } + + /// Run `zcash-devtool wallet -w [--server .. --connection direct]`. + fn run(&self, subcommand: &str, extra: &[&str], lwd_port: Option) -> Result { + self.run_with_stdin(subcommand, extra, lwd_port, None) + } + + /// Like [`Funder::run`], but optionally pipes `stdin_input` to the child's + /// stdin. The Ironwood devtool's `wallet init` reads its mnemonic from + /// stdin (it no longer takes `--mnemonic`). + fn run_with_stdin( + &self, + subcommand: &str, + extra: &[&str], + lwd_port: Option, + stdin_input: Option<&str>, + ) -> Result { + 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 cmd = Command::new(&self.bin); + cmd.args(&args); + let output = if let Some(input) = stdin_input { + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + let mut child = cmd + .spawn() + .with_context(|| format!("spawn devtool {subcommand}"))?; + child + .stdin + .take() + .expect("piped stdin") + .write_all(input.as_bytes()) + .with_context(|| format!("write stdin to devtool {subcommand}"))?; + child + .wait_with_output() + .with_context(|| format!("wait for devtool {subcommand}"))? + } else { + cmd.output() + .with_context(|| format!("spawn 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()) + } +} + +// =============================== zkv (the system under test) =============================== + +/// Locate the built `zkv` binary: `$ZKV_BIN` if set, else the parent +/// workspace's release build. +pub fn zkv_bin() -> PathBuf { + if let Ok(p) = std::env::var("ZKV_BIN") { + return PathBuf::from(p); + } + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .parent() + .map(|p| p.join("target/release/zkv")) + .unwrap_or_else(|| PathBuf::from("zkv")) +} + +/// The captured result of one `zkv` invocation. +pub struct ZkvOutput { + pub status_ok: bool, + pub stdout: String, + pub stderr: String, +} + +/// Drives the real `zkv` CLI binary against an isolated data directory and a +/// local regtest lightwalletd. Every method is a fresh subprocess, exactly as +/// a user runs the tool; nothing shortcuts through the library. +pub struct Zkv { + bin: PathBuf, + data_dir: tempfile::TempDir, + /// `host:port` of the regtest lightwalletd, passed as `--server` to every + /// chain-touching command. + server: String, +} + +impl Zkv { + /// Set up an isolated zkv home pointed at the given lightwalletd gRPC + /// port. Pre-creates the `.demo-oracles-provisioned` marker so the CLI's + /// one-time demo auto-provision (which would dial the public testnet on + /// every command until it succeeds) never fires in the sandboxed run. + pub fn new(lwd_grpc_port: u16) -> Result { + let bin = zkv_bin(); + if !bin.is_file() { + bail!( + "zkv binary not found at {} - build it first (cargo build --release -p zkv \ + --bin zkv) or set $ZKV_BIN", + bin.display() + ); + } + let data_dir = tempfile::tempdir().context("create zkv data dir")?; + std::fs::write(data_dir.path().join(".demo-oracles-provisioned"), b"") + .context("write demo marker")?; + Ok(Zkv { + bin, + data_dir, + server: format!("127.0.0.1:{lwd_grpc_port}"), + }) + } + + /// The data directory (owned by this handle; deleted when it drops). + pub fn data_dir(&self) -> &Path { + self.data_dir.path() + } + + fn command(&self, db: Option<&str>, args: &[&str]) -> Command { + let mut cmd = Command::new(&self.bin); + cmd.arg("--data-dir").arg(self.data_dir.path()); + if let Some(db) = db { + cmd.args(["--db", db]); + } + cmd.args(args); + cmd + } + + /// Run an offline `zkv` command (no `--server`), capturing output. + pub fn run_local(&self, db: Option<&str>, args: &[&str]) -> Result { + let out = self + .command(db, args) + .output() + .with_context(|| format!("spawn zkv {args:?}"))?; + Ok(capture(out)) + } + + /// Run a chain-touching `zkv` command with `--server` appended, capturing + /// output. + pub fn run_online(&self, db: Option<&str>, args: &[&str]) -> Result { + let mut full: Vec<&str> = args.to_vec(); + full.extend_from_slice(&["--server", &self.server]); + self.run_local(db, &full) + } + + /// Like [`Zkv::run_online`] but errors (with both streams attached) + /// unless the command exited 0. Returns stdout. + pub fn ok_online(&self, db: Option<&str>, args: &[&str]) -> Result { + let out = self.run_online(db, args)?; + ensure_ok(&out, args)?; + Ok(out.stdout) + } + + /// Like [`Zkv::run_local`] but errors unless the command exited 0. + /// Returns stdout. + pub fn ok_local(&self, db: Option<&str>, args: &[&str]) -> Result { + let out = self.run_local(db, args)?; + ensure_ok(&out, args)?; + Ok(out.stdout) + } + + /// Create a fresh regtest database non-interactively (no INIT broadcast + /// yet; the wallet is unfunded). Returns `(zkv_address, funding_ua)`. + pub fn create_db(&self, name: &str) -> Result<(String, String)> { + self.ok_online( + None, + &["init", name, "--network", "regtest", "--non-interactive"], + ) + .context("zkv init --non-interactive")?; + let addr = self.address(name)?; + let funding = self.funding_address(name)?; + Ok((addr, funding)) + } + + /// The database's `zkvregtest1...` address (offline read). + pub fn address(&self, name: &str) -> Result { + Ok(self.ok_local(Some(name), &["address"])?.trim().to_owned()) + } + + /// The database's shielded funding UA (offline read). + pub fn funding_address(&self, name: &str) -> Result { + Ok(self + .ok_local(Some(name), &["address", "--funding"])? + .trim() + .to_owned()) + } + + /// Resume `zkv init` on a funded database to broadcast INIT and wait for + /// its confirmation, mining a block every couple of seconds so the poll + /// loop can make progress. `zkv init`'s poll interval is 15s, so this + /// completes in a few cycles. + pub async fn init_until_confirmed( + &self, + name: &str, + zebrad: &Zebrad, + timeout: Duration, + ) -> Result<()> { + // Inherit stderr so the poll loop's status lines land in the test log + // (visible with --nocapture); stdout carries only funding info we + // already have. + let mut child = self + .command(None, &["init", name, "--init-timeout", "600"]) + .args(["--server", &self.server]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .context("spawn zkv init (resume)")?; + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait().context("wait on zkv init")? { + if status.success() { + return Ok(()); + } + bail!("zkv init (resume) exited with {status}"); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + bail!("zkv init did not confirm INIT within {timeout:?}"); + } + zebrad + .generate_blocks(1) + .await + .context("mine while waiting for INIT")?; + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + + /// `zkv set `; returns the broadcast txid. + pub fn set(&self, name: &str, key: &str, value: &str) -> Result { + Ok(self + .ok_online(Some(name), &["set", key, value])? + .trim() + .to_owned()) + } + + /// `zkv del `; returns the broadcast txid. + pub fn del(&self, name: &str, key: &str) -> Result { + Ok(self.ok_online(Some(name), &["del", key])?.trim().to_owned()) + } + + /// `zkv get --output json -c `: `Ok(Some(value))` + /// for a confirmed value, `Ok(None)` when the key has no confirmed value, + /// `Err` for anything else (not initialized, sync failure, ...). + pub fn get(&self, name: &str, key: &str, confirmations: u32) -> Result> { + let confs = confirmations.to_string(); + let out = self.run_online( + Some(name), + &["get", key, "--output", "json", "--confirmations", &confs], + )?; + // The command exits 1 for a key without a confirmed value but still + // prints the JSON record; only a run with no JSON on stdout is a real + // failure. + let parsed: Value = match serde_json::from_str(out.stdout.trim()) { + Ok(v) => v, + Err(_) => bail!( + "zkv get {key} produced no JSON (exit ok: {}):\nstdout: {}\nstderr: {}", + out.status_ok, + out.stdout, + tail(&out.stderr, 15), + ), + }; + Ok(parsed + .get("value") + .and_then(|v| v.as_str()) + .map(|s| s.to_owned())) + } + + /// `zkv keys --output json`: the matching key names. + pub fn keys(&self, name: &str, pattern: &str, confirmations: u32) -> Result> { + let confs = confirmations.to_string(); + let stdout = self.ok_online( + Some(name), + &[ + "keys", + pattern, + "--output", + "json", + "--confirmations", + &confs, + ], + )?; + let parsed: Value = serde_json::from_str(stdout.trim()).context("parse keys JSON")?; + Ok(parsed + .get("keys") + .and_then(|k| k.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(|s| s.to_owned())) + .collect() + }) + .unwrap_or_default()) + } + + /// `zkv history --all --output json -c `, parsed. + pub fn history_json(&self, name: &str, confirmations: u32) -> Result { + let confs = confirmations.to_string(); + let stdout = self.ok_online( + Some(name), + &[ + "history", + "--all", + "--output", + "json", + "--confirmations", + &confs, + ], + )?; + serde_json::from_str(stdout.trim()).context("parse history JSON") + } + + /// `zkv roles -c ` with stdout piped, so the stable + /// one-record-per-line machine format is returned. + pub fn roles_raw(&self, name: &str, confirmations: u32) -> Result { + let confs = confirmations.to_string(); + self.ok_online(Some(name), &["roles", "--confirmations", &confs]) + } + + /// `zkv balance`: the total balance in TAZ, as printed on stdout + /// (` TAZ`; only the number is returned). + pub fn balance(&self, name: &str) -> Result { + let stdout = self.ok_online(Some(name), &["balance"])?; + stdout + .split_whitespace() + .next() + .unwrap_or("") + .parse::() + .with_context(|| format!("parse balance {stdout:?}")) + } + + /// `zkv watch
`: import a watch-only replica. + pub fn watch(&self, address: &str, name: &str) -> Result<()> { + self.ok_online(None, &["watch", address, name]).map(|_| ()) + } + + /// `zkv inspect
--output json` (offline), parsed. + pub fn inspect_json(&self, address: &str) -> Result { + let stdout = self.ok_local(None, &["inspect", address, "--output", "json"])?; + serde_json::from_str(stdout.trim()).context("parse inspect JSON") + } + + /// `zkv shallow get --address `: the db-less shallow read. + /// Returns the bare value printed for a single exact key. + pub fn shallow_get(&self, address: &str, key: &str, max_depth: u32) -> Result { + let depth = max_depth.to_string(); + let stdout = self.ok_online( + None, + &[ + "shallow", + "get", + key, + "--address", + address, + "--max-depth", + &depth, + "--confirmations", + "1", + ], + )?; + Ok(stdout.trim().to_owned()) + } +} + +fn capture(out: Output) -> ZkvOutput { + ZkvOutput { + status_ok: out.status.success(), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } +} + +fn ensure_ok(out: &ZkvOutput, args: &[&str]) -> Result<()> { + if out.status_ok { + return Ok(()); + } + bail!( + "zkv {args:?} failed:\nstdout: {}\nstderr: {}", + out.stdout, + tail(&out.stderr, 25), + ) +} + +// =============================== helpers =============================== + +/// JSON-RPC 2.0 call to zebrad; returns the `result` or an error carrying the +/// message. +async fn zebra_rpc_call(url: &str, method: &str, params: Value) -> Result { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }); + let resp = reqwest::Client::new() + .post(url) + .json(&body) + .send() + .await + .context("zebra rpc request")?; + let envelope: Value = resp.json().await.context("decode zebra rpc response")?; + if let Some(err) = envelope.get("error").filter(|e| !e.is_null()) { + bail!("zebra rpc error from {method}: {err}"); + } + Ok(envelope.get("result").cloned().unwrap_or(Value::Null)) +} + +fn tail(s: &str, lines: usize) -> String { + let all: Vec<&str> = s.lines().collect(); + all[all.len().saturating_sub(lines)..].join("\n") +} diff --git a/regtest-harness/tests/regtest_kv.rs b/regtest-harness/tests/regtest_kv.rs new file mode 100644 index 0000000..073560d --- /dev/null +++ b/regtest-harness/tests/regtest_kv.rs @@ -0,0 +1,413 @@ +//! Funded regtest end-to-end: the full zkv database lifecycle on a real chain. +//! +//! Regtest can't mine a coinbase straight into an Orchard note, so funds reach +//! zkv the way the protocol allows: mine a **transparent** coinbase to a +//! funding wallet (`zcash-devtool`), let it mature (100 blocks), **shield** it +//! into Orchard, then **send** TAZ to the zkv wallet's funding UA. Everything +//! runs on a **single chain**: the funder's transparent address is derived +//! offline and zebra mines straight to it, so the funder's birthday anchor is +//! taken from the same chain it spends on. +//! +//! What this proves that the offline unit tests can't: the wire protocol +//! round-trips through a real chain end to end. Signed memos survive the +//! actual broadcast path, replay + authorization run over genuinely mined +//! blocks, the version-CAS `[seq]` prefix advances across an overwrite, a +//! watch-only import bootstrapped from nothing but the `zkvregtest1...` +//! address converges on the same state, and the shallow (db-less) client +//! verifies the same writes against the same chain. `sync.rs` / `write.rs` / +//! `send.rs` have no offline coverage at all; this is their test. +//! +//! Phases: +//! 1. stack up + fund the funder (mine, mature, shield). +//! 2. `zkv init --non-interactive` (create), fund the wallet, `zkv init` +//! resume: INIT broadcast + confirmation. +//! 3. data ops: SET (create), SET (overwrite, seq > 0 on the wire), a second +//! key, DEL with tombstone, `keys` globbing. +//! 4. `history`: the append-only signed log (INIT genesis entry, every +//! write verified, creator attribution). +//! 5. roles: WRITERADD/WRITERDEL management memos against a second +//! database's key, registry + revocation tombstone. +//! 6. watch-only replica from the address alone reads the same state; a +//! duplicate import is refused. +//! 7. shallow (db-less) read of the same key from the bare address. +//! +//! Skips cleanly unless `ZEBRAD_BIN`, `LIGHTWALLETD_BIN` and `DEVTOOL_BIN` are +//! all set (see README.md). + +use std::time::{Duration, Instant}; + +use zkv_regtest_harness::{resolve_bin, Funder, Lightwalletd, Zebrad, Zkv}; + +/// Coinbase blocks mined to the funder up front. zebra finalizes blocks deeper +/// than `MAX_BLOCK_REORG_HEIGHT` (= coinbase maturity - 1 = 99) below the tip; +/// only finalized blocks are persisted and survive the miner-swap restart, so +/// mining 120 finalizes the funder's coinbases at heights ~1..21 (the +/// non-finalized rest are dropped on restart, which is what keeps the funder +/// from ever holding an immature coinbase). +const FUNDER_COINBASES: u32 = 120; +/// After restarting mining to a throwaway address, mine this many blocks: the +/// restart resets the tip to the finalized height (~21) and this tail re-grows +/// the chain so the surviving funder coinbases are well 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"; +/// 0.1 TAZ: covers the INIT fee plus every write below with lots of headroom +/// (each write costs only the ZIP-317 fee, ~0.0001). +const FUND_ZATOSHIS: u64 = 10_000_000; +/// External (untrusted) receives are spendable at 10 confirmations under the +/// default ZIP-315 policy; a couple extra cover tip skew. +const FUNDING_CONFIRMATIONS: u32 = 12; +/// Blocks mined after each zkv broadcast: 1 confirms the write for +/// `--confirmations 1` reads, 3 make the change note spendable again for the +/// *next* write (trusted change confirms at 3), 1 spare for tip skew. +const CONFIRM_BLOCKS: u32 = 5; + +const DB: &str = "db"; +const READER: &str = "reader"; +const DELEGATE: &str = "delegate"; + +#[tokio::test] +async fn regtest_kv_lifecycle() { + let (Some(zebrad_bin), Some(lwd_bin), Some(devtool_bin)) = ( + resolve_bin("ZEBRAD_BIN"), + resolve_bin("LIGHTWALLETD_BIN"), + resolve_bin("DEVTOOL_BIN"), + ) else { + eprintln!( + "SKIP regtest_kv_lifecycle: set ZEBRAD_BIN, LIGHTWALLETD_BIN and DEVTOOL_BIN to run \ + the funded e2e (see README.md). The harness still compiled and linked." + ); + return; + }; + + // ---- Phase 1: chain + funder ------------------------------------------------- + let funder_taddr = Funder::derive_transparent_address(&devtool_bin) + .expect("derive funder transparent address"); + let mut zebrad = Zebrad::start_with_miner(&zebrad_bin, &funder_taddr) + .await + .expect("start 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(&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"); + // The shielded note must reach the trusted confirmation depth (3) before + // the funder can spend it; extra blocks cover tip skew. + zebrad.generate_blocks(6).await.expect("confirm shield"); + funder.sync(lwd.grpc_port).expect("funder sync (shielded)"); + + // ---- Phase 2: create + fund + INIT ------------------------------------------- + let zkv = Zkv::new(lwd.grpc_port).expect("set up zkv home"); + let (zkv_addr, funding_ua) = zkv.create_db(DB).expect("zkv init --non-interactive"); + assert!( + zkv_addr.starts_with("zkvregtest1"), + "expected a zkvregtest1 address, got {zkv_addr}" + ); + assert!( + funding_ua.starts_with("uregtest1"), + "expected a uregtest1 funding UA, got {funding_ua}" + ); + + // The address is self-describing: `inspect` (fully offline) must recover + // the network, pool, and the creator's signing key from the string alone. + let info = zkv.inspect_json(&zkv_addr).expect("inspect own address"); + assert_eq!(info["network"], "regtest"); + // Regtest is testnet-flavored, so the default pool is Ironwood (see + // `config::default_pool_for_network`); the db is created without `--pool`. + assert_eq!(info["pool"], "ironwood"); + let creator_key = info["signing_key"] + .as_str() + .expect("signing_key in inspect JSON") + .to_owned(); + assert!( + creator_key.starts_with("zkvid1"), + "expected a zkvid1 signing key, got {creator_key}" + ); + + funder + .send(lwd.grpc_port, &funding_ua, FUND_ZATOSHIS) + .expect("fund the zkv wallet"); + zebrad + .generate_blocks(FUNDING_CONFIRMATIONS) + .await + .expect("confirm the funding send to spendability"); + + zkv.init_until_confirmed(DB, &zebrad, Duration::from_secs(300)) + .await + .expect("broadcast + confirm INIT"); + // INIT spent the funding note; its change must reach the trusted + // confirmation depth (3) before the first data write can spend it. + zebrad + .generate_blocks(CONFIRM_BLOCKS) + .await + .expect("confirm INIT change"); + + let balance = zkv.balance(DB).expect("balance after funding"); + assert!( + balance > 0.0, + "expected a positive spendable balance, got {balance}" + ); + + // ---- Phase 3: data ops -------------------------------------------------------- + // SET (create). + zkv.set(DB, "greeting", "hello").expect("set greeting"); + zebrad.generate_blocks(CONFIRM_BLOCKS).await.expect("mine"); + wait_for_value(&zkv, DB, "greeting", Some("hello")).await; + + // Regression guard for the Ironwood self-send memo. A zkv write is a shielded + // payment to the database's OWN address, so the writer reads its value back + // from its own *received* note. Compact-block scanning stores that note with a + // NULL memo; librustzcash's `backfill_self_send_memos` pass fills it in + // post-scan from the stored raw transaction. The read path no longer carries a + // `sent_notes` fallback (that band-aid was removed once the received note + // became authoritative), so a regression in that backfill would surface here + // as a NULL memo and this read would return None. + assert_eq!( + zkv.get(DB, "greeting", 1).expect("read own greeting"), + Some("hello".to_owned()), + "writer must read its own Ironwood self-send memo back from the received note, \ + not a sent_notes fallback" + ); + + // SET (overwrite): last-write-wins, and the second write must carry a + // nonzero replay-protection sequence on the wire (asserted in Phase 4). + zkv.set(DB, "greeting", "world") + .expect("overwrite greeting"); + zebrad.generate_blocks(CONFIRM_BLOCKS).await.expect("mine"); + wait_for_value(&zkv, DB, "greeting", Some("world")).await; + + // A second key, then DEL it: the tombstone must win. + zkv.set(DB, "temp", "42").expect("set temp"); + zebrad.generate_blocks(CONFIRM_BLOCKS).await.expect("mine"); + wait_for_value(&zkv, DB, "temp", Some("42")).await; + + let keys = zkv.keys(DB, "*", 1).expect("keys glob"); + assert!( + keys.contains(&"greeting".to_owned()) && keys.contains(&"temp".to_owned()), + "keys '*' should list both live keys, got {keys:?}" + ); + + zkv.del(DB, "temp").expect("del temp"); + zebrad.generate_blocks(CONFIRM_BLOCKS).await.expect("mine"); + wait_for_value(&zkv, DB, "temp", None).await; + let keys = zkv.keys(DB, "*", 1).expect("keys glob after del"); + assert!( + keys.contains(&"greeting".to_owned()) && !keys.contains(&"temp".to_owned()), + "keys '*' should drop the deleted key, got {keys:?}" + ); + + // ---- Phase 4: history ---------------------------------------------------------- + let history = zkv.history_json(DB, 1).expect("history"); + assert_eq!( + history["creator"].as_str(), + Some(creator_key.as_str()), + "history creator must be the address-derived signing key" + ); + let entries = history["entries"].as_array().expect("history entries"); + let ops: Vec<(&str, &str)> = entries + .iter() + .map(|e| { + ( + e["op"].as_str().unwrap_or(""), + e["key"].as_str().unwrap_or(""), + ) + }) + .collect(); + assert!( + ops.iter().any(|(op, _)| *op == "INIT"), + "history must show the genesis INIT entry, got {ops:?}" + ); + assert_eq!( + ops.iter() + .filter(|(op, key)| (*op == "SET" || *op == "SETL") && *key == "greeting") + .count(), + 2, + "history must show both greeting writes, got {ops:?}" + ); + assert!( + ops.iter().any(|(op, key)| *op == "DEL" && *key == "temp"), + "history must show the DEL, got {ops:?}" + ); + // Every confirmed entry carries a valid signature by an authorized signer. + for e in entries { + assert_eq!( + e["verified"].as_bool(), + Some(true), + "history entry not verified: {e}" + ); + } + // The overwrite must have consumed a fresh replay-protection sequence: + // at least one greeting SET rides the wire with seq >= 1 (the compact + // `[seq]` prefix on the signature line). + let greeting_seqs: Vec = entries + .iter() + .filter(|e| e["key"] == "greeting") + .filter_map(|e| e["seq"].as_u64()) + .collect(); + assert!( + greeting_seqs.iter().any(|s| *s >= 1), + "the greeting overwrite must carry a nonzero wire seq, got {greeting_seqs:?}" + ); + + // ---- Phase 5: roles (management opcodes) --------------------------------------- + // A second database supplies a real foreign pubkey to delegate to (its + // wallet needs no funds; the key exists as soon as the db does). Mine a + // fresh block first: creating a database pins a birthday against the tip + // and refuses a tip older than TIP_MAX_AGE (5 minutes). + zebrad.generate_blocks(1).await.expect("freshen tip"); + let (delegate_addr, _) = zkv.create_db(DELEGATE).expect("create delegate db"); + let delegate_key = zkv.inspect_json(&delegate_addr).expect("inspect delegate")["signing_key"] + .as_str() + .expect("delegate signing key") + .to_owned(); + assert_ne!(delegate_key, creator_key); + + let roles = zkv.roles_raw(DB, 1).expect("roles before grant"); + assert!( + has_line(&roles, &format!("creator {creator_key}")), + "roles must name the creator, got:\n{roles}" + ); + assert!( + has_line(&roles, &format!("owner {creator_key}")), + "the creator must be owner #1 after INIT, got:\n{roles}" + ); + + zkv.ok_online( + Some(DB), + &["roles", "writer", "add", &delegate_key, "CREATE,UPDATE"], + ) + .expect("roles writer add"); + zebrad.generate_blocks(CONFIRM_BLOCKS).await.expect("mine"); + wait_for( + || { + let roles = zkv.roles_raw(DB, 1)?; + Ok(has_line( + &roles, + &format!("writer {delegate_key} CREATE,UPDATE"), + )) + }, + "writer grant visible in roles", + ) + .await; + + zkv.ok_online(Some(DB), &["roles", "writer", "remove", &delegate_key]) + .expect("roles writer remove"); + zebrad.generate_blocks(CONFIRM_BLOCKS).await.expect("mine"); + wait_for( + || { + let roles = zkv.roles_raw(DB, 1)?; + // NB: line-anchored matching; a `revoked-writer ...` line + // *contains* the substring `writer ...`. + let live_writer = roles + .lines() + .any(|l| l.starts_with(&format!("writer {delegate_key}"))); + let tombstone = roles + .lines() + .any(|l| l.starts_with(&format!("revoked-writer {delegate_key}"))); + Ok(!live_writer && tombstone) + }, + "writer revocation + tombstone visible in roles", + ) + .await; + + // ---- Phase 6: watch-only replica from the address alone ------------------------ + // A separate zkv home plays the independent reader: it holds no seed and + // knows nothing but the zkvregtest1... address string. (In the writer's + // own home the import is refused by the duplicate-identity guard, since + // the admin database is the same database.) + zebrad.generate_blocks(1).await.expect("freshen tip"); + let reader_zkv = Zkv::new(lwd.grpc_port).expect("set up reader zkv home"); + reader_zkv.watch(&zkv_addr, READER).expect("watch import"); + wait_for_value(&reader_zkv, READER, "greeting", Some("world")).await; + assert_eq!( + reader_zkv.get(READER, "temp", 1).expect("reader get temp"), + None, + "the DEL tombstone must hold on the watch-only replica" + ); + // Re-importing the same database under another name is refused, both as a + // second watch in the reader's home and as a watch beside the admin + // database that *is* this database. + for (home, label) in [(&reader_zkv, "reader home"), (&zkv, "writer home")] { + let dup = home + .run_online(None, &["watch", &zkv_addr, "reader2"]) + .expect("spawn duplicate watch"); + assert!( + !dup.status_ok && dup.stderr.contains("already imported"), + "duplicate watch import in the {label} must be refused, got ok={} stderr:\n{}", + dup.status_ok, + dup.stderr + ); + } + + // ---- Phase 7: shallow (db-less) read -------------------------------------------- + let tip = zebrad.tip_height().await.expect("tip height") as u32; + let value = zkv + .shallow_get(&zkv_addr, "greeting", tip) + .expect("shallow get"); + assert_eq!( + value, "world", + "shallow read from the bare address must agree with the full replay" + ); +} + +/// Whether `output` has a line starting with `prefix` (roles' raw records are +/// one per line; substring matching is unsafe because `revoked-writer ` +/// contains `writer `). +fn has_line(output: &str, prefix: &str) -> bool { + output.lines().any(|l| l.starts_with(prefix)) +} + +/// Poll `zkv get` (which syncs on every call) until the key's confirmed value +/// matches, tolerating lightwalletd's ingestion lag behind freshly-mined +/// blocks. +async fn wait_for_value(zkv: &Zkv, db: &str, key: &str, expected: Option<&str>) { + let want = expected.map(|s| s.to_owned()); + wait_for( + || Ok(zkv.get(db, key, 1)? == want), + &format!("{db}: {key} == {expected:?}"), + ) + .await; +} + +/// Retry `check` every 2s until it returns true, failing after 90s. The +/// condition closure may itself error transiently (e.g. a sync racing the +/// indexer) for the first half of the window. +async fn wait_for(mut check: impl FnMut() -> anyhow::Result, what: &str) { + let deadline = Instant::now() + Duration::from_secs(90); + let mut last_err: Option; + loop { + match check() { + Ok(true) => return, + Ok(false) => last_err = None, + // Tolerate transient errors early in the window; persist and they + // fail the wait below. + Err(e) => last_err = Some(e), + } + if Instant::now() >= deadline { + match last_err { + Some(e) => panic!("timed out waiting for {what}; last error: {e:#}"), + None => panic!("timed out waiting for {what}"), + } + } + tokio::time::sleep(Duration::from_secs(2)).await; + } +} diff --git a/regtest-harness/tests/regtest_unfunded.rs b/regtest-harness/tests/regtest_unfunded.rs new file mode 100644 index 0000000..3245ec1 --- /dev/null +++ b/regtest-harness/tests/regtest_unfunded.rs @@ -0,0 +1,113 @@ +//! Unfunded regtest surface: everything zkv can (and must refuse to) do +//! before a database holds funds or an INIT. Needs only zebrad + lightwalletd +//! (no funding wallet), so it runs even where `DEVTOOL_BIN` is unavailable. +//! +//! Covers: creating a regtest database against a live chain (birthday pinned +//! near the tip), the self-describing `zkvregtest1...` address (offline +//! `inspect` recovers network/pool/keys), the not-initialized read refusal, +//! the zero balance, and `list` visibility. +//! +//! Skips cleanly unless `ZEBRAD_BIN` and `LIGHTWALLETD_BIN` are set. + +use zkv_regtest_harness::{resolve_bin, Lightwalletd, Zebrad, Zkv}; + +/// Enough chain for `zkv init` to pin a birthday: the near-tip default backs +/// off `BIRTHDAY_SAFETY_BUFFER` (10) blocks and fetches the tree state one +/// block below that, so the tip must comfortably exceed 12. +const INITIAL_BLOCKS: u32 = 25; + +#[tokio::test] +async fn regtest_unfunded_surface() { + let (Some(zebrad_bin), Some(lwd_bin)) = + (resolve_bin("ZEBRAD_BIN"), resolve_bin("LIGHTWALLETD_BIN")) + else { + eprintln!( + "SKIP regtest_unfunded_surface: set ZEBRAD_BIN and LIGHTWALLETD_BIN to run the live \ + assertions (see README.md). The harness still compiled and linked." + ); + return; + }; + + let zebrad = Zebrad::start(&zebrad_bin).await.expect("start zebrad"); + zebrad + .generate_blocks(INITIAL_BLOCKS) + .await + .expect("mine initial blocks"); + let lwd = Lightwalletd::start(&lwd_bin, zebrad.rpc_port) + .await + .expect("start lightwalletd"); + + let zkv = Zkv::new(lwd.grpc_port).expect("set up zkv home"); + let (addr, funding) = zkv.create_db("db").expect("zkv init --non-interactive"); + assert!( + addr.starts_with("zkvregtest1"), + "expected a zkvregtest1 address, got {addr}" + ); + assert!( + funding.starts_with("uregtest1"), + "expected a uregtest1 funding UA, got {funding}" + ); + + // The address is fully self-describing: offline `inspect` recovers the + // network, pool, birthday, and the creator's signing key from the string. + let info = zkv.inspect_json(&addr).expect("inspect"); + assert_eq!(info["network"], "regtest"); + // Regtest is testnet-flavored, so the default pool is Ironwood (see + // `config::default_pool_for_network`); `create_db` passes no `--pool`. + assert_eq!(info["pool"], "ironwood"); + let birthday = info["birthday"].as_u64().expect("birthday"); + assert!( + birthday >= 1 && birthday <= u64::from(INITIAL_BLOCKS), + "near-tip birthday should land on the young chain, got {birthday}" + ); + assert!(info["signing_key"] + .as_str() + .is_some_and(|k| k.starts_with("zkvid1"))); + assert!(info["funding_address"] + .as_str() + .is_some_and(|f| f.starts_with("uregtest1"))); + assert!(info["receiver"] + .as_str() + .is_some_and(|r| r.starts_with("regtest:"))); + + // Reads must refuse a never-INITed database rather than serve empty state. + let get = zkv + .run_online(Some("db"), &["get", "somekey", "--confirmations", "1"]) + .expect("spawn get"); + assert!( + !get.status_ok && get.stderr.contains("not initialized"), + "get on an uninitialized database must fail with the init hint, got ok={} stderr:\n{}", + get.status_ok, + get.stderr + ); + + // A brand-new wallet is empty. (Tolerate the pre-first-scan "no wallet + // summary" state; a summary that exists must be zero.) + match zkv.balance("db") { + Ok(balance) => assert_eq!(balance, 0.0, "fresh wallet must hold nothing"), + Err(e) => assert!( + format!("{e:#}").contains("no wallet summary"), + "unexpected balance failure: {e:#}" + ), + } + + // And visible in `list` (an offline command). + let list = zkv.ok_local(None, &["list"]).expect("list"); + assert!(list.contains("db"), "list must show the database:\n{list}"); + + // Re-running init non-interactively on the existing, unfunded database is + // the documented resume path: it must succeed (exit 0) and ask for funds + // rather than recreate or corrupt anything. + let resume = zkv + .run_online( + None, + &["init", "db", "--network", "regtest", "--non-interactive"], + ) + .expect("spawn init resume"); + assert!( + resume.status_ok && resume.stderr.contains("Fund the wallet"), + "non-interactive resume must ask for funding, got ok={} stderr:\n{}", + resume.status_ok, + resume.stderr + ); +}