From f1d2befe620a22faca347fc8c56493e52e38c60e Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 15 Sep 2026 08:49:01 -0400 Subject: [PATCH 1/7] Squashed 'zaino/' changes from 07695fac5e..62e74097d7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 62e74097d7 Merge pull request #1535 from zingolabs/rc-0.10.0 c9d25de82a build: bump crate versions and section changelogs for 0.10.0 75ef86d0cd Merge pull request #1533 from zingolabs/dev ff735e994e Merge pull request #1534 from zingolabs/chore/backport-0.9.0-into-dev b5bee6555f build(live-tests): record zaino-proto 0.5.0 in the lockfile d3804dee07 docs(zaino-proto): drop the unreleased ShieldedProtocol::Ironwood entry e239ce876e docs(zaino-state): move the chain-store entry out of the released 0.8.0 section cb092daf1b Merge 0.9.0 (stable) into dev 7bd73b4e47 Merge pull request #1531 from zingolabs/fix/restore-deleted-adrs 06f7a3747e docs: restore ADRs 0002-0004 deleted in #1265 12dd84a779 Merge pull request #1265 from zingolabs/feat_migrate_tests c53526d1d0 test fixes and cleanup d461a98b6c update ztest CI workflow 9fd5bb9b3a test: migrate the live suite to the ztest k8s harness 5eefa018bb Merge pull request #1462 from zingolabs/feat/release-pipeline aeca5cd6a4 Merge pull request #1521 from zingolabs/fix/pr1462-review-findings 6bc26e132b fix(ci): apply the sync PR's labels at creation and exempt it from the rename bot 5cb654d515 fix(ci): resolve the workflow findings from the review of PR #1462 03e5f84d55 fix(relman): resolve the review findings pinned by the failing tests 1e5eb5473f test(relman): pin the review findings of PR #1462 with failing tests 7d20d67504 feat(backport-sentinel): gate auto-merge behind RELMAN_BACKPORT_AUTOMERGE (default off) 8b7acdf00d feat(release): first-cut gate-suite manifest with real test classification ecccf0dbcd docs(release): specify suite-membership criterion, manifest schema, answer envelope 2aadb6bf4e docs(release): mark the rc-gate publisher as a reference, not a live producer 317b9406de docs(release): record the wired rc-gate publisher and manual deployment sign-off 99dfdcdc1f feat(release): wire the rc-gate signal publisher and a manual deployment sign-off b27a3183d7 docs(adr): one ledger — fold decision_records/ into docs/adr/ 905ac06560 docs(adr): record the release-pipeline decision as ADR-0016; periodic flow becomes ADR-0015 28fd6f588f docs(release): reclassify the release documents as specifications under docs/release/ 251530d726 Merge branch 'dev' into feat/release-pipeline 1f1251a2d7 docs: state the decision-record ledger rule and the relationship to docs/adr/ c183129c77 docs(release): consolidate the trust model in implementation.md 51279f81fc docs(release): restore periodic.md as a superseded record 78eec7261c Merge pull request #1502 from zingolabs/pr1483-review-fixes 5c402b231b feat(chain-store-zainodb): instrument the whole read path via a labelled DB_READ_SECONDS{op} 94c238d3bd chore(metrics): drop mempool metric names that no crate emits 03c23afe99 refactor(chain-store): seal per-port CAPABILITY so no backend can override it 0a90745b44 refactor(chain-store-zainodb): split backend-state from capability refusal and type FeatureUnavailable fdccaf650d Merge pull request #1483 from zingolabs/feat/isolate_finalised_state_plus_ports_rebased b17bc5d711 Merge remote-tracking branch 'origin/dev' into feat/isolate_finalised_state_plus_ports_rebased d27ec93038 Merge pull request #1498 from zingolabs/fix/source-macros-description 7c03ef7aa1 fix(zaino-source-macros): add description required for crates.io publish 2aa88789cd Merge pull request #1497 from zingolabs/build/bump-0.9.0 78030dd44c build: bump crate versions and section changelogs for 0.9.0 0dc6658eb5 Merge pull request #1496 from zingolabs/rc/0.9.0 afd609eeb5 Merge pull request #1395 from zingolabs/remove_zcashd 8c823f6108 feat!: remove zcashd support entirely c89fb9b10b docs(chain-store): record the review's changes in the usage guides 07b018862e refactor(chain-index): split the chain-store adapter into reading and driving 0ce6a95a9f refactor(chain-store-zainodb): rename ports to adapter and split it up 6a034327c8 fix: typo 43e9c5df31 feat(chain-store-zainodb): instrument the finalised read path 552e736133 refactor(chain-store): hold PoolFilter's pools as a set 860100d5eb refactor(chain-store): return a typed, checked net value from address effects e5e9014636 refactor(chain-store): read a store's advertised capability off the port 6199c39f2a fix(metrics): define each metric name once, in the crate that emits it 97cbbf4882 refactor(chain-store-zainodb): share one header mapping between both directions 94d86c4a0a fix(state): route a store error by naming it, not by excluding one case 5196467088 fix(chain-index): tell a client an unbuilt index is not a server fault c641b1d2e5 fix(chain-store): name a corrupt row as corruption, not as a missing one df533932b2 fix(chain-store-zainodb): hand the backend's cause across the port boundary c4182e98a1 refactor(chain-store): let a store error carry its cause d9ac7bc2e4 fix(chain-index): log a finalised compact-block read failure before falling back aa7f3f9fa6 fix(chain-index): propagate the store's typed error from get_tx_out_set_info fb7512b444 fix(chain-store-zainodb): map a routing refusal to the capability it denied 1e6a0f7f5e Merge branch 'dev' into feat/isolate_finalised_state_plus_ports_rebased 96489cddfa Merge pull request #1487 from zingolabs/fix/chain-head-domain-not-found-exhaustive 76b352612c chain-head-service: match named domain not-found variant exhaustively 09b4a66f2b docs: ADR 0012, changelogs and usage guides for the chain store e21ba0cbb3 test(chain-store-zainodb): move the finalised-state suites into the backend crate 5f311bf399 refactor(state): read the finalised state through the chain store 43f74d9218 feat(chain-store-zainodb): implement the chain-store ports 93d4425141 feat(chain-store-zainodb): move the finalised state into the backend crate 63706dfd2a feat(chain-store-zainodb): move the persisted types into the backend crate 2c5f4884b1 fix(chain-store): name the validator questions the store actually asks 7290559c84 feat(chain-store): add zaino-chain-store, the ChainStore domain crate cd2804049b Merge pull request #1475 from zingolabs/dev a7e5179e3e Merge branch 'dev' into feat/release-pipeline e642c6a66e feat(primitives): add the chain store's shared vocabulary a1a84883f8 feat(encoding): add zaino-encoding 3d1f954c47 refactor(state): move MempoolInfo into zaino-primitives 31a84a4b5e fix(state): repair the experimental feature set and build it in CI 29e2792e79 fix(state): map the current schema version to its capability set 09aa206d0f add golden vector tests to ensure code is moved correctly 4379113f02 Merge pull request #1473 from zingolabs/chore/backport_0_8_0_onto_dev_with_conflict_fixes 06c596e63f Merge remote-tracking branch 'origin/dev' into chore/backport_0_8_0_onto_dev_with_conflict_fixes 69fe29006a Merge pull request #1459 from zingolabs/feat/resilient-seam 197c56bf34 Merge branch 'dev' into feat/resilient-seam 5b5b75d570 Merge pull request #1471 from zingolabs/fix/ironwood_proto ae7e88ff5d Merge branch 'dev' into feat/resilient-seam 2ec2904492 docs(source): route consumers who want their own retrying at the seal 11e0f29eeb docs(chain-index): note first ValidatorClient use and the OneShot punt fc32ce7570 fix(chain-index): wrap boot source in ValidatorClient for adopt_network 2c035f4713 Merge pull request #1467 from zingolabs/add_sync_plus_concurrency_tests 4097f229df merge conflicts 4e529f2002 test and fmt fixs b3314e93b7 Merge branch 'fix_chain_head_premature_ready' (PR #1470) 3de102c186 refactor: anchor the status cell at Syncing directly 9f915e905a refactor: anchor the status cell at Syncing directly 30aa68b0a5 refactor: express store as apply with a constant closure 54132da867 refactor: express store as apply with a constant closure fa1263cab2 refactor: pure status-transition rule applied by compare-and-swap 3b0438d64e refactor: pure status-transition rule applied by compare-and-swap 76057b6863 fix: publish chain-head Ready only after the first catch-up tick cc30eb28eb fix: publish chain-head Ready only after the first catch-up tick 0ad4cb82f5 Merge branch 'dev' into feat/release-pipeline 0c6cd52f5f Merge branch 'dev' into feat/resilient-seam b819583a1a Add Ironwood subtree root RPC plumbing c9e363a105 docs: replace machine-specific paths with placeholders 82f74dc12b docs(perf): final concurrency figures — 5,000 at 100% in both modes 25e1a15120 fix(zaino-state): raise the LMDB reader ceiling off its floor a48a6c4a96 test(zaino-bench): cover the spawn-ramp arithmetic fdf7044526 fix(zaino-bench): hold every connection open until the round starts 56bc55263e docs(perf): record concurrency and serve-rate measurements 24de8ea11f update concurrency test 621cf11275 docs(perf): record the first full mainnet sync measurement 14720e7413 perf(zaino-state): assemble blocks concurrently, not just fetch them d7268feea4 perf(zaino-state): fetch blocks concurrently during bulk sync b7e479b8df perf(zaino-state): pipeline bulk-sync batch commits with block building 67b63100b5 fix sync test reading 7ab2a61355 fixes df666723aa feat(zaino-bench): benchmark harness for sync time, concurrency, and serve rate 4bec33f57c refactor(rc-gate): decouple the gate from its suite (named-signal indirection) 32849c7ea5 Merge pull request #1466 from zingolabs/sync/stable-to-dev 73c8c59be7 Merge branch 'dev' into sync/stable-to-dev 489522d16a docs(deployment-gate): validation is a dial (warm-tip fixtures ↔ full-sync), automated and/or manual fe696af8c6 ci(cutover): wire rc-gate's e2e precondition; flag release.yaml c3a2877fd4 change(relman): drop the `v` from per-crate version tags bf97bc1866 feat(deployment-gate): build the GitHub side of the bridge 88db176857 Merge pull request #1457 from zingolabs/fix/release_0_8_0/slow_accumulator_build_and_fs_switchover 2bdd892ec5 ci(changeset-rename): pass PR head ref via env (script-injection hardening) 6566471fc5 ci(release-pr): label bot PRs for scannability 0fca7a61db fix(backport-sentinel): self-heal auto-merge; retry past the mergeability race c8cbfaa920 ci(release-pr): hyperlink tag/commit refs in the timeline comments 9855e5e906 ci(hotfix-notice): quote the fix subject, not the merge commit dc02499ae4 ci(release-pr): append-only comment timeline + live body snapshot eb81bac918 test(sandbox): add hotfix stage (land a fix directly on rc) 1447d1e313 test(sandbox): add reset stage to the cycle driver bf58af89f6 test(sandbox): step-by-step cycle driver for live pipeline demos cc8fbf5cc7 Merge remote-tracking branch 'origin/feat/release-pipeline' into feat/release-pipeline 59d19e5009 test(sandbox): drive consume→ledger→bless→sentinel end-to-end check 0bcde265a4 feat(relman): consumed-UID ledger for backport-independent dedup 236f57927f Merge branch 'dev' into feat/release-pipeline cd86d27349 style(it): fix shellcheck findings in the changeset-check harness 328308cfb4 feat(backport-sentinel): auto-merge the sync PR, self-dissolving when clean a9bb2ab3da test(sandbox): deploy + drive a targeted backport-sentinel check b12414d190 ci(backport-sentinel): carry stable back into dev after a release/hotfix 5be3befa45 docs(pipeline): blessing marks changesets consumed, not clears 1c7d78d429 feat(relman): give every changeset an immutable UID at creation 9eb28a6902 refactor(source): name the resilient wrapper for what it is, ValidatorClient 68f4971529 feat(relman): mark changesets consumed instead of erasing them ad644c07b3 ci(blessing): pre-flight publishability check before any commit/tag/release cacfd65013 style(relman-core): rustfmt cycle_status 1a37076a3c docs(readme): add Release pipeline badges 6971a1d8de ci(release-pr-body): gather cycle status and render the dashboard 5754b22e06 docs+tools: make the deployment-gate testable without waiting days cac7f0d6e1 feat(relman): render release-cycle dashboard in pr-body 50bcb97120 feat(relman-core): add CycleStatus input type 2f2b4bea2d ci(blessing): derive tags/publish-plan before clearing changesets; add GitHub Release cac7830876 ci: release-pr-body creates the PR via the App token 5a307128fa ci: rename bot pushes via App token so the dev-gate re-runs 82899cf599 test(relman): local act+podman integration test for changeset-check 68067b9ea8 ci(relman): run changeset-check via setup-relman, not cargo run b0401b53df ci(relman): add setup-relman composite action e93a80cde4 tools: add fork pipeline-sandbox setup script 5a65cc42c5 ci: add RELMAN_PUBLISH_DRY_RUN toggle to blessing bc8b531606 docs(release): rename test-type "soak" to "deployment" 8ae6e73dcc ci: drop last RELEASE_TOKEN mention in blessing comment cc3efa01fd ci: use a GitHub App token (not a PAT) for protected-branch pushes 6b004c5a6a ci(release): add blessing workflow (release on merge to stable) bae59a3ce1 ci(release): add rc-gate advancement and release-PR-body workflows 9c80ecee8c ci(release): add CODEOWNERS template and pr- changeset rename bot 3e2368fa39 relman: rustfmt the new changeset test bodies 2cead556e1 relman: wire changeset rename/clear CLI commands 627930fe81 relman: add rename_to_pr/clear/list to Changesets port 6976beaec2 relman: add rename/remove to ChangesetStore port fbbd5713cc ci: wire relman into CI (lint/test + advisory dev-gate changeset check) a166e756fc relman-cli: warn on stderr about skipped unfilled changeset templates 9e4c6f7cea relman-core+domain: tolerate unfilled templates in aggregation, flag in check 7eb569b054 relman-core: distinguish unfilled changeset templates from malformed ab72abbdc9 docs(source): document the two-layer resilient ports; fmt the series 8f860def42 feat(source): forward subscriptions through Resilient + assert the port bound 337e12d0f7 feat(source): derive resilient twins for all idempotent ports db46766489 feat(source): add #[resilient_port] proc-macro + seal the resilient ports 46d149a59d refactor(source): rename SendRawTransaction port to OneShotSendRawTransaction f629877c6f refactor(state): finish OneShotGetRawMempoolTransaction ref updates a711886c03 refactor(source): rename GetTxOut port to OneShotGetTxOut f9409c3219 refactor(source): rename GetTreestate port to OneShotGetTreestate d6c936b8c9 refactor(source): rename GetSpentInfo/GetSubtreeRoots ports to OneShot* 355265a01f refactor(source): rename GetRawBlock/GetRawBlockByHash/GetRawBlockHeader ports to OneShot* 521cea456a refactor(source): rename GetNodeInfo port to OneShotGetNodeInfo dc2684566e refactor(source): rename GetMiningInfo port to OneShotGetMiningInfo e1db71096d refactor(source): rename GetMempoolTxids port to OneShotGetMempoolTxids 99d3a56409 refactor(source): rename GetMempoolSourceTip port to OneShotGetMempoolSourceTip ac87dbf0f2 refactor(source): rename GetDifficulty/GetMempoolMetadata ports to OneShot* a26d38ded7 refactor(source): rename GetPreIndexCompactBlock port to OneShotGetPreIndexCompactBlock 3884950ad4 refactor(source): rename GetChainTips/GetCommitmentTreeRoots ports to OneShot* 25966df7f4 refactor(source): rename GetChainTip port to OneShotGetChainTip 8bae538e8f refactor(source): rename GetBlockSubsidy/GetBlockVerbose/GetBlockchainInfo ports to OneShot* c5f5eea94c refactor(source): rename GetBlockHeader port to OneShotGetBlockHeader 18a6d24445 refactor(source): rename GetBlockDeltas port to OneShotGetBlockDeltas e38df93517 refactor(source): rename GetBlockByHash port to OneShotGetBlockByHash c55666cbde refactor(source): rename GetBlock port to OneShotGetBlock b5c74e5e9d refactor(source): rename GetBestBlockHeight port to OneShotGetBestBlockHeight 1c02fb41f0 refactor(source): rename GetAddressTxids/GetAddressUtxos ports to OneShot* 775580f222 refactor(source): rename GetAddressBalance/GetAddressDeltas ports to OneShot* 3757275ca0 feat(relman): add --version flag and walk-up relman.toml discovery f6f93fb1e8 relman: wire tags, pr-body, publish-plan CLI commands 3a5f430ac8 relman: ReleaseArtifacts port + service (tags, pr-body, publish-plan) e8a524f1b7 relman: add CycleId, Tag, TagPlan, PublishPlan core types 5ea373e85b relman: wire relman changelog [--dry-run] command 46304e86cd relman: add FsChangelogStore filesystem adapter 56215d40d8 relman: add ChangelogService implementing the Changelog port bc2daa695b relman: add pure Keep-a-Changelog render functions a44cf7c325 relman: add ChangelogStore + Changelog ports and MapChangelogStore mock 3266ba01a1 relman: wire `relman bump [--dry-run]` command 2ca7daf144 relman: add BumpService applying a derived BumpTable e95cd3c569 relman: add toml_edit ManifestEditor adapter a2e4ae1544 relman: add ManifestEditor + ApplyBump ports and recording mock cacbf8a754 relman: add `relman versions` command and wire VersionService 3b722221e0 relman: add CargoMetadataWorkspace adapter ac18dfb1c3 relman: add VersionService deriving direct + transitive bumps 6e6bd1278f relman: add Workspace driven port and Versions driving port 51d83c4216 relman: add Version, Bump, BumpTable core types 2a29ef9400 relman: update lockfile for relman-domain -> relman-config edge 2f3addfc2a relman: wire changeset check command through the CLI and composition root a95d9667be relman-adapters: add GitVcs implementing the Vcs port via git diff de04ce4a09 relman-domain: add ChangesetCheckService enforcing changeset coverage b47c00ee9e relman-core: add Vcs port and changeset-check driving port 5cc0132fd3 relman: wire changeset new command through the CLI and composition root 4103885db5 relman-adapters: add crate with FsChangesetStore and RandomSlugSource 51f8cacc9a relman-domain: add ChangesetService implementing the Changesets port 778a5d2ab8 relman-core: add in-memory changeset store and slug source mocks 5bf988ff70 relman-core: add Slug type and changeset ports c6e722c587 relman-core: add Changeset model with TOML parse/serialize 0e9c79d470 relman-core: add Description and ChangeEntry changeset value types 1859ef1c86 relman-core: add ChangeKind and Section changeset value types 5d01a6cfca feat(relman): govern all 17 publishable crates eda3225556 Add repo-root relman.toml with the 13 governed targets 573d8d9b0e relman-config: parse relman.toml into typed ReleaseConfig a5451295a5 relman-core: add CrateName, WorkspacePath, ReleaseOptions, Target newtypes b648657c1f Merge pull request #1440 from zingolabs/feat/isolate_nfs d48dcec3a2 review fixes: upgrade test u8 and make conversion fallible 194f2f6ce3 feat(relman): scaffold isolated hexagonal CLI skeleton (slice 0) 452dfc5a15 docs(release): relman hexagon structure + relman.toml target manifest 88dee0faaa docs(release): add implementation architecture sub-spec cc56c9ab36 docs(release): add changeset format sub-spec dd5802213b docs(release): tool home is a sibling relman crate, not workbench 97be9f4acf docs(release): record impl approach and workflow teardown f6ce191954 docs(release): redesign release pipeline spec aeac94abab fix: makers test cont. 7d0e8cff73 fix: makers test f751366160 add fs ephemeral and txoutset build logging c780a9ba10 docs(state): keep the chain head entries in Unreleased e8c64e7e64 refactor(chain-head-service): let the snapshot own its generation rule a935d74898 refactor(state): convert the tree-size cast instead of truncating 679193b5c0 refactor(chain-head): make illegal config unrepresentable e4d037e12c docs(chain-head-service): shutdown aborts, it does not await 4942a0c33c docs(chain-head-service): state who owns the writer task's lifetime 079e01501e refactor(primitives): promote one epoch type instead of two identical ones 5b6382fb9b refactor(chain-head): delete the error variants nothing produces 1df807ad1e docs: record the chain head separation and the crates it arrived with 00902f5ae7 refactor(state): remove the non-finalised state and read the chain head 80e070d637 feat(chain-head-service): move the non-finalised state into its own runtime 2333f74785 feat(chain-head): add zaino-chain-head, the ChainHead domain crate git-subtree-dir: zaino git-subtree-split: 62e74097d7ee52a81a8c6386c43d51bc8b756715 --- .config/nextest.toml | 56 - .dockerignore | 9 + .dupes-ignore.toml | 21 + .env.testing-artifacts | 19 - .github/CODEOWNERS | 22 + .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/actions/cycle-ids/action.yml | 63 + .github/actions/setup-relman/action.yml | 52 + .github/workflows/auto-tag-rc.yml | 6 +- .github/workflows/backport-sentinel.yml | 198 + .github/workflows/blessing.yml | 351 + .github/workflows/build-n-push-ci-image.yaml | 28 +- .github/workflows/changeset-check.yml | 72 + .github/workflows/changeset-rename.yml | 127 + .github/workflows/ci-nightly.yaml | 54 +- .github/workflows/ci.yml | 136 +- .github/workflows/compute-tag.yml | 3 +- .github/workflows/deployment-advance.yml | 98 + .github/workflows/deployment-signoff.yml | 106 + .github/workflows/final-tag-on-stable.yml | 6 +- .github/workflows/hotfix-notice.yml | 63 + .github/workflows/live-tests.yaml | 129 + .github/workflows/publish-dry-run.yml | 17 +- .github/workflows/rc-gate.yml | 226 + .github/workflows/release-pr-body.yml | 168 + .github/workflows/release.yaml | 23 +- .gitignore | 8 +- .release/gate-suites.toml | 59 + CHANGELOG.md | 117 + CLAUDE.md | 36 +- CONTEXT.md | 4 +- CONTRIBUTING.md | 2 +- Cargo.lock | 293 +- Cargo.toml | 118 +- Dockerfile | 2 +- Makefile.toml | 258 +- README.md | 88 +- docs/adr/0001-zcashd-support-feature-gate.md | 116 - ...-live-test-taxonomy-and-two-crate-split.md | 2 +- docs/adr/0005-zcashd-support-default-off.md | 48 - ...0008-source-ports-and-domain-primitives.md | 2 +- ...served-json-schema-lives-in-zaino-serve.md | 10 +- docs/adr/0010-mempool-subsystem-separation.md | 2 +- .../0011-chain-head-subsystem-separation.md | 169 + .../0012-chain-store-subsystem-separation.md | 177 + .../0015-periodic-release-flow.md} | 7 + ...0016-changeset-derived-release-pipeline.md | 41 + docs/adr/README.md | 27 + docs/docker.md | 8 - .../zainod-bench-mainnet-ephemeral.toml | 46 + .../example_configs/zainod-bench-mainnet.toml | 73 + docs/example_configs/zainod.toml | 4 +- docs/example_configs/zebrad_config_3.1.0.toml | 4 +- docs/internal_spec.md | 8 +- docs/logging.md | 12 +- docs/perf.md | 369 + docs/release/changeset-format.md | 348 + docs/release/implementation.md | 302 + docs/release/pipeline.md | 1164 ++++ docs/rpc_api.md | 2 +- docs/testing.md | 138 +- docs/updating_zebra_crates.md | 6 +- docs/use_cases.md | 4 +- flake.lock | 26 +- flake.nix | 14 +- live-tests/CLAUDE.md | 106 + live-tests/CONTEXT.md | 56 - live-tests/Cargo.lock | 6184 +++++++++++++++++ live-tests/Cargo.toml | 32 + live-tests/clientless/Cargo.toml | 73 +- live-tests/clientless/src/lib.rs | 150 +- live-tests/clientless/tests/chain_cache.rs | 925 +-- .../tests/compact_block_consistency.rs | 640 +- live-tests/clientless/tests/fetch_service.rs | 1347 ++-- .../clientless/tests/finalisation_seam.rs | 289 + live-tests/clientless/tests/json_server.rs | 468 -- live-tests/clientless/tests/state_service.rs | 1410 ++-- live-tests/clientless/tests/test_vectors.rs | 12 +- .../the_pub_testnet_ironwood_boundary.rs | 146 - .../clientless/tests/validator_heights.rs | 199 +- live-tests/e2e/Cargo.toml | 60 +- live-tests/e2e/src/devtool.rs | 418 -- live-tests/e2e/src/lib.rs | 30 +- live-tests/e2e/tests/compact_block_wire.rs | 330 +- live-tests/e2e/tests/devtool.rs | 2476 ------- live-tests/e2e/tests/devtool_zcashd.rs | 526 -- live-tests/e2e/tests/ironwood_activation.rs | 1237 ++-- live-tests/e2e/tests/test_vectors.rs | 704 -- live-tests/e2e/tests/wallet_to_validator.rs | 3787 ++++++++++ live-tests/quick_test.txt | 94 - live-tests/test_binaries/bins/.gitinclude | 0 live-tests/test_environment/Containerfile | 201 +- live-tests/test_environment/entrypoint.sh | 64 - live-tests/zaino-testutils/Cargo.toml | 85 +- live-tests/zaino-testutils/src/finalised.rs | 52 + live-tests/zaino-testutils/src/hex.rs | 69 + live-tests/zaino-testutils/src/json.rs | 281 + .../zaino-testutils/src/legacy_parser.rs | 27 +- .../src/legacy_parser/block.rs | 1 - .../src/legacy_parser/indexed_block.rs | 274 - live-tests/zaino-testutils/src/lib.rs | 1636 +---- live-tests/zaino-testutils/src/rpc.rs | 36 + .../zaino-testutils/src/validator_oracle.rs | 49 - nix/package.nix | 8 +- packages/zaino-address/CHANGELOG.md | 19 + packages/zaino-address/Cargo.toml | 2 +- packages/zaino-address/src/classify.rs | 6 +- packages/zaino-address/src/lib.rs | 2 +- packages/zaino-address/src/sapling.rs | 14 +- packages/zaino-address/src/validated.rs | 8 +- packages/zaino-address/usage.md | 4 +- packages/zaino-bench/Cargo.toml | 44 + packages/zaino-bench/src/chain.rs | 195 + packages/zaino-bench/src/concurrent.rs | 663 ++ packages/zaino-bench/src/error.rs | 59 + packages/zaino-bench/src/grpc_client.rs | 146 + packages/zaino-bench/src/main.rs | 78 + packages/zaino-bench/src/metrics.rs | 244 + packages/zaino-bench/src/serve.rs | 264 + packages/zaino-bench/src/stats.rs | 94 + packages/zaino-bench/src/sync.rs | 402 ++ packages/zaino-bench/usage.md | 177 + .../zaino-chain-head-service/CHANGELOG.md | 106 + packages/zaino-chain-head-service/Cargo.toml | 51 + .../zaino-chain-head-service/src/error.rs | 58 + packages/zaino-chain-head-service/src/lib.rs | 51 + .../src/metric_names.rs | 16 + .../zaino-chain-head-service/src/service.rs | 1013 +++ .../zaino-chain-head-service/src/snapshot.rs | 353 + .../src/subscriber.rs | 105 + .../zaino-chain-head-service/src/tests.rs | 713 ++ packages/zaino-chain-head-service/usage.md | 142 + packages/zaino-chain-head/CHANGELOG.md | 94 + packages/zaino-chain-head/Cargo.toml | 34 + packages/zaino-chain-head/src/block.rs | 85 + packages/zaino-chain-head/src/config.rs | 145 + packages/zaino-chain-head/src/error.rs | 29 + packages/zaino-chain-head/src/lib.rs | 65 + packages/zaino-chain-head/src/ports.rs | 152 + packages/zaino-chain-head/src/snapshot.rs | 252 + packages/zaino-chain-head/src/transparent.rs | 87 + packages/zaino-chain-head/usage.md | 139 + .../zaino-chain-store-zainodb/CHANGELOG.md | 139 + packages/zaino-chain-store-zainodb/Cargo.toml | 111 + .../zaino-chain-store-zainodb/src/adapter.rs | 642 ++ .../src/adapter/error_map.rs | 293 + .../src/adapter/from_domain.rs | 352 + .../src/adapter/history.rs | 140 + .../src/adapter/to_domain.rs | 613 ++ .../zaino-chain-store-zainodb/src/config.rs | 135 + .../src/conversion.rs | 410 ++ .../src}/entry.rs | 49 +- .../zaino-chain-store-zainodb/src/error.rs | 123 + .../zaino-chain-store-zainodb/src/golden.rs | 608 ++ packages/zaino-chain-store-zainodb/src/lib.rs | 55 + .../src/metric_names.rs | 48 + .../zaino-chain-store-zainodb/src/pool.rs | 73 + .../src/store.rs} | 875 ++- .../src/store}/CHANGELOG.md | 0 .../src/store}/capability.rs | 545 +- .../src/store}/finalised_source.rs | 319 +- .../store}/finalised_source/db_schema_v1.txt | 0 .../src/store}/finalised_source/ephemeral.rs | 451 +- .../src/store}/finalised_source/v1.rs | 230 +- .../store}/finalised_source/v1/block_core.rs | 73 +- .../finalised_source/v1/block_shielded.rs | 115 +- .../finalised_source/v1/block_transparent.rs | 57 +- .../finalised_source/v1/compact_block.rs | 635 +- .../finalised_source/v1/indexed_block.rs | 128 +- .../store}/finalised_source/v1/read_core.rs | 73 +- .../v1/transparent_address_history.rs | 152 +- .../v1/tx_out_set_accumulator.rs | 572 +- .../store}/finalised_source/v1/validation.rs | 100 +- .../store}/finalised_source/v1/write_core.rs | 714 +- .../src/store}/migrations.rs | 312 +- .../src/store}/reader.rs | 264 +- .../src/store}/router.rs | 296 +- .../zaino-chain-store-zainodb/src/stream.rs | 41 + .../zaino-chain-store-zainodb/src/support.rs | 33 + .../zaino-chain-store-zainodb/src/tests.rs | 44 + .../src/tests/fake_validator.rs | 287 + .../src}/tests/finalised_state.rs | 23 +- .../src}/tests/finalised_state/ephemeral.rs | 144 +- .../src}/tests/finalised_state/migrations.rs | 0 .../migrations/v1_0_to_v1_1.rs | 67 +- .../migrations/v1_1_to_v1_2.rs | 114 +- .../migrations/v1_2_to_v1_3.rs | 75 +- .../src/tests/finalised_state/ports.rs | 687 ++ .../src}/tests/finalised_state/v1.rs | 254 +- .../src/tests/fixtures.rs | 236 + .../src/tests/vectors.rs | 169 + .../src}/tests/vectors/faucet_data.json | 0 .../src}/tests/vectors/recipient_data.json | 0 .../src}/tests/vectors/tree_roots.dat | Bin .../src}/tests/vectors/tree_states.dat | Bin .../vectors/v1_test_db/regtest/v1/data.mdb | Bin .../src}/tests/vectors/zcash_blocks.dat | Bin .../zaino-chain-store-zainodb/src/types.rs | 57 + .../src}/types/block_context.rs | 0 .../src}/types/db.rs | 0 .../src}/types/db/address.rs | 0 .../src}/types/db/block.rs | 30 +- .../src}/types/db/commitment.rs | 21 +- .../src}/types/db/legacy.rs | 37 +- .../src}/types/db/metadata.rs | 247 +- .../src}/types/db/primitives.rs | 0 .../src}/types/db/shielded.rs | 0 .../src}/types/db/transaction.rs | 0 .../src/types/fixtures.rs} | 18 +- .../src}/types/helpers.rs | 22 +- .../src}/types/primitives.rs | 0 .../src}/types/primitives/block_index.rs | 2 +- .../src}/types/primitives/chain_work.rs | 0 .../types/primitives/compact_difficulty.rs | 0 packages/zaino-chain-store-zainodb/usage.md | 271 + packages/zaino-chain-store/CHANGELOG.md | 159 + packages/zaino-chain-store/Cargo.toml | 56 + packages/zaino-chain-store/src/block.rs | 280 + packages/zaino-chain-store/src/capability.rs | 275 + packages/zaino-chain-store/src/config.rs | 182 + packages/zaino-chain-store/src/error.rs | 335 + packages/zaino-chain-store/src/lib.rs | 84 + packages/zaino-chain-store/src/output.rs | 85 + packages/zaino-chain-store/src/ports.rs | 515 ++ packages/zaino-chain-store/src/transparent.rs | 259 + packages/zaino-chain-store/src/txout_set.rs | 549 ++ packages/zaino-chain-store/usage.md | 449 ++ packages/zaino-common/CHANGELOG.md | 21 + packages/zaino-common/Cargo.toml | 2 +- packages/zaino-common/src/config/storage.rs | 3 + packages/zaino-common/src/config/validator.rs | 2 +- packages/zaino-consensus/CHANGELOG.md | 18 + packages/zaino-consensus/Cargo.toml | 2 +- packages/zaino-consensus/src/lib.rs | 2 +- packages/zaino-convert-zebra/CHANGELOG.md | 29 + packages/zaino-convert-zebra/Cargo.toml | 2 +- packages/zaino-convert-zebra/src/lib.rs | 18 +- packages/zaino-encoding/CHANGELOG.md | 43 + packages/zaino-encoding/Cargo.toml | 21 + .../encoding.rs => zaino-encoding/src/lib.rs} | 0 packages/zaino-encoding/usage.md | 66 + packages/zaino-mempool-service/CHANGELOG.md | 29 + packages/zaino-mempool-service/Cargo.toml | 2 +- .../zaino-mempool-service/src/coherence.rs | 7 +- .../src/coherence/publish.rs | 5 +- .../src/coherence/reconcile.rs | 7 +- packages/zaino-mempool-service/src/tests.rs | 21 +- packages/zaino-mempool/CHANGELOG.md | 35 + packages/zaino-mempool/Cargo.toml | 2 +- .../zaino-mempool/docs/mempool_lifecycle.md | 2 +- packages/zaino-mempool/src/entry.rs | 2 +- packages/zaino-mempool/src/event.rs | 6 +- packages/zaino-mempool/src/lib.rs | 2 +- packages/zaino-mempool/src/ports.rs | 49 +- packages/zaino-mempool/src/tip.rs | 17 +- packages/zaino-mempool/usage.md | 9 +- packages/zaino-primitives/CHANGELOG.md | 40 + packages/zaino-primitives/Cargo.toml | 2 +- packages/zaino-primitives/src/types.rs | 12 +- .../src/types/address_delta.rs | 2 +- packages/zaino-primitives/src/types/block.rs | 16 +- .../src/types/block_tx_position.rs | 73 + .../src/types/chain_state_epoch.rs | 40 + .../src/types/equihash_solution.rs | 34 + .../src/types/mempool_info.rs | 29 + .../zaino-primitives/src/types/outpoint.rs | 21 + packages/zaino-primitives/src/types/rpc.rs | 2 +- .../src/types/rpc/address_deltas.rs | 4 +- .../src/types/rpc/mining_info.rs | 2 +- .../src/types/rpc/peer_info.rs | 6 +- packages/zaino-primitives/src/types/script.rs | 181 +- .../src/types/shielded_pool.rs | 10 + packages/zaino-proto/CHANGELOG.md | 26 + packages/zaino-proto/Cargo.toml | 2 +- packages/zaino-proto/README.md | 31 + .../lightwallet-protocol/CHANGELOG.md | 51 +- .../lightwallet-protocol/README.md | 60 + .../walletrpc/compact_formats.proto | 21 +- .../walletrpc/service.proto | 60 +- .../zaino-proto/src/proto/compact_formats.rs | 19 +- packages/zaino-proto/src/proto/service.rs | 95 +- packages/zaino-rpc/CHANGELOG.md | 29 + packages/zaino-rpc/Cargo.toml | 2 +- packages/zaino-rpc/src/error.rs | 2 +- packages/zaino-rpc/src/retry.rs | 2 +- packages/zaino-rpc/usage.md | 4 +- packages/zaino-serve/CHANGELOG.md | 26 + packages/zaino-serve/Cargo.toml | 8 +- packages/zaino-serve/src/rpc/grpc/service.rs | 4 +- .../zaino-serve/src/rpc/jsonrpc/service.rs | 133 +- packages/zaino-serve/src/rpc/jsonrpc/wire.rs | 4 +- .../src/rpc/jsonrpc/wire/address.rs | 125 +- .../src/rpc/jsonrpc/wire/address_deltas.rs | 4 +- .../src/rpc/jsonrpc/wire/address_queries.rs | 2 +- .../src/rpc/jsonrpc/wire/block_deltas.rs | 4 +- .../src/rpc/jsonrpc/wire/block_header.rs | 16 +- .../src/rpc/jsonrpc/wire/block_subsidy.rs | 4 +- .../src/rpc/jsonrpc/wire/blockchain_info.rs | 2 +- .../src/rpc/jsonrpc/wire/chain_tips.rs | 2 +- .../src/rpc/jsonrpc/wire/hashes.rs | 2 +- .../src/rpc/jsonrpc/wire/mining_info.rs | 25 +- .../zaino-serve/src/rpc/jsonrpc/wire/misc.rs | 15 +- .../src/rpc/jsonrpc/wire/peer_info.rs | 347 +- .../src/rpc/jsonrpc/wire/subtrees.rs | 10 +- packages/zaino-source-macros/CHANGELOG.md | 26 + packages/zaino-source-macros/Cargo.toml | 21 + packages/zaino-source-macros/src/lib.rs | 225 + .../zaino-source-zebra-readstate/CHANGELOG.md | 28 + .../zaino-source-zebra-readstate/Cargo.toml | 2 +- .../src/adapter.rs | 56 +- packages/zaino-source-zebra-rpc/CHANGELOG.md | 28 + packages/zaino-source-zebra-rpc/Cargo.toml | 2 +- .../examples/fetch_fixtures.rs | 2 +- .../zaino-source-zebra-rpc/examples/smoke.rs | 2 +- .../zaino-source-zebra-rpc/src/adapter.rs | 94 +- packages/zaino-source-zebra-rpc/src/parse.rs | 4 +- .../tests/block_parity.rs | 2 +- packages/zaino-source-zebra-rpc/usage.md | 6 - packages/zaino-source-zebra/CHANGELOG.md | 28 + packages/zaino-source-zebra/Cargo.toml | 2 +- packages/zaino-source-zebra/src/routing.rs | 82 +- packages/zaino-source-zebra/usage.md | 2 +- packages/zaino-source/CHANGELOG.md | 46 + packages/zaino-source/Cargo.toml | 3 +- .../zaino-source/src/get_address_balance.rs | 3 +- .../zaino-source/src/get_address_deltas.rs | 3 +- .../zaino-source/src/get_address_txids.rs | 3 +- .../zaino-source/src/get_address_utxos.rs | 3 +- .../zaino-source/src/get_best_block_height.rs | 3 +- packages/zaino-source/src/get_block.rs | 3 +- .../zaino-source/src/get_block_by_hash.rs | 3 +- packages/zaino-source/src/get_block_deltas.rs | 3 +- packages/zaino-source/src/get_block_header.rs | 3 +- .../zaino-source/src/get_block_subsidy.rs | 3 +- .../zaino-source/src/get_block_verbose.rs | 6 +- .../zaino-source/src/get_blockchain_info.rs | 3 +- packages/zaino-source/src/get_chain_tip.rs | 3 +- packages/zaino-source/src/get_chain_tips.rs | 3 +- .../src/get_commitment_tree_roots.rs | 3 +- .../zaino-source/src/get_compact_block.rs | 3 +- packages/zaino-source/src/get_difficulty.rs | 3 +- .../zaino-source/src/get_mempool_metadata.rs | 5 +- .../src/get_mempool_source_tip.rs | 3 +- .../zaino-source/src/get_mempool_txids.rs | 3 +- packages/zaino-source/src/get_mining_info.rs | 3 +- .../zaino-source/src/get_network_sol_ps.rs | 3 +- packages/zaino-source/src/get_node_info.rs | 3 +- packages/zaino-source/src/get_peer_info.rs | 3 +- packages/zaino-source/src/get_raw_block.rs | 6 +- .../zaino-source/src/get_raw_block_header.rs | 3 +- .../src/get_raw_mempool_transaction.rs | 3 +- packages/zaino-source/src/get_spent_info.rs | 13 +- .../zaino-source/src/get_subtree_roots.rs | 3 +- packages/zaino-source/src/get_transaction.rs | 3 +- packages/zaino-source/src/get_treestate.rs | 3 +- .../zaino-source/src/get_treestate_by_hash.rs | 3 +- packages/zaino-source/src/get_tx_out.rs | 3 +- packages/zaino-source/src/lib.rs | 112 +- packages/zaino-source/src/mock.rs | 34 +- packages/zaino-source/src/polled_chain_tip.rs | 12 +- .../zaino-source/src/send_raw_transaction.rs | 2 +- .../src/{resilient.rs => validator_client.rs} | 173 +- packages/zaino-source/usage.md | 48 +- packages/zaino-state/CHANGELOG.md | 175 + packages/zaino-state/Cargo.toml | 31 +- packages/zaino-state/src/chain_index.rs | 1497 ++-- .../zaino-state/src/chain_index/chain_head.rs | 275 + .../src/chain_index/chain_store.rs | 101 + .../src/chain_index/chain_store/driving.rs | 37 + .../src/chain_index/chain_store/reading.rs | 545 ++ .../zaino-state/src/chain_index/mempool.rs | 113 +- .../src/chain_index/non_finalised_state.rs | 1038 --- .../zaino-state/src/chain_index/source.rs | 28 +- .../chain_index/source/mockchain_source.rs | 154 +- .../src/chain_index/source_ports.rs | 128 +- packages/zaino-state/src/chain_index/tests.rs | 86 +- .../src/chain_index/tests/chain_head.rs | 139 + .../src/chain_index/tests/golden.rs | 611 ++ .../src/chain_index/tests/mockchain_tests.rs | 175 +- .../chain_index/tests/non_finalised_state.rs | 290 - .../chain_index/tests/proptest_blockgen.rs | 375 +- .../src/chain_index/tests/sync_loop.rs | 97 +- .../src/chain_index/tests/vectors.rs | 459 +- packages/zaino-state/src/chain_index/types.rs | 67 +- .../zaino-state/src/chain_index/types/wire.rs | 159 - .../src/chain_index/validator_source.rs | 123 +- .../zaino-state/src/chain_index/wire_types.rs | 188 + packages/zaino-state/src/config.rs | 39 +- packages/zaino-state/src/error.rs | 326 +- packages/zaino-state/src/indexer.rs | 108 +- .../src/indexer/node_backed_indexer.rs | 280 +- packages/zaino-state/src/lib.rs | 93 +- packages/zaino-state/src/source_caps.rs | 155 +- packages/zaino-state/src/stream.rs | 37 +- packages/zaino-status/CHANGELOG.md | 20 + packages/zaino-status/Cargo.toml | 2 +- packages/zaino-status/src/status.rs | 57 +- packages/zaino-status/usage.md | 9 + packages/zainod/CHANGELOG.md | 39 + packages/zainod/Cargo.toml | 13 +- packages/zainod/README.md | 2 +- packages/zainod/src/config.rs | 2 +- packages/zainod/src/indexer.rs | 11 + packages/zainod/src/metrics.rs | 50 +- relman.toml | 118 + tools/relman/Cargo.lock | 856 +++ tools/relman/Cargo.toml | 49 + tools/relman/README.md | 15 + tools/relman/crates/adapters/Cargo.toml | 21 + .../adapters/src/cargo_metadata_workspace.rs | 335 + .../crates/adapters/src/fs_changelog_store.rs | 82 + .../crates/adapters/src/fs_changeset_store.rs | 214 + .../crates/adapters/src/fs_consumed_ledger.rs | 92 + tools/relman/crates/adapters/src/git_vcs.rs | 120 + tools/relman/crates/adapters/src/lib.rs | 26 + .../crates/adapters/src/random_slug_source.rs | 84 + .../crates/adapters/src/random_uid_source.rs | 58 + .../adapters/src/toml_edit_manifest_editor.rs | 303 + tools/relman/crates/app/Cargo.toml | 24 + tools/relman/crates/app/src/main.rs | 244 + tools/relman/crates/app/tests/cli.rs | 210 + tools/relman/crates/cli/Cargo.toml | 13 + tools/relman/crates/cli/src/app.rs | 34 + tools/relman/crates/cli/src/commands.rs | 11 + tools/relman/crates/cli/src/commands/about.rs | 14 + tools/relman/crates/cli/src/commands/bump.rs | 60 + .../crates/cli/src/commands/changelog.rs | 62 + .../crates/cli/src/commands/changeset.rs | 98 + .../cli/src/commands/changeset/check.rs | 37 + .../cli/src/commands/changeset/clear.rs | 49 + .../cli/src/commands/changeset/consume.rs | 60 + .../crates/cli/src/commands/changeset/new.rs | 30 + .../cli/src/commands/changeset/rename.rs | 34 + .../relman/crates/cli/src/commands/pr_body.rs | 81 + .../crates/cli/src/commands/publish_plan.rs | 31 + tools/relman/crates/cli/src/commands/tags.rs | 39 + .../crates/cli/src/commands/versions.rs | 26 + tools/relman/crates/cli/src/context.rs | 22 + tools/relman/crates/cli/src/format.rs | 78 + tools/relman/crates/cli/src/lib.rs | 23 + tools/relman/crates/cli/src/warn.rs | 25 + tools/relman/crates/config/Cargo.toml | 19 + tools/relman/crates/config/src/config.rs | 58 + tools/relman/crates/config/src/error.rs | 55 + tools/relman/crates/config/src/lib.rs | 17 + tools/relman/crates/config/src/load.rs | 413 ++ tools/relman/crates/core/Cargo.toml | 19 + tools/relman/crates/core/src/lib.rs | 20 + tools/relman/crates/core/src/mocks.rs | 30 + .../crates/core/src/mocks/changelog_store.rs | 57 + .../crates/core/src/mocks/changeset_store.rs | 104 + tools/relman/crates/core/src/mocks/clock.rs | 20 + .../core/src/mocks/consumed_ledger_store.rs | 47 + .../relman/crates/core/src/mocks/fixtures.rs | 8 + .../crates/core/src/mocks/manifest_editor.rs | 71 + .../crates/core/src/mocks/slug_source.rs | 41 + .../crates/core/src/mocks/uid_source.rs | 38 + tools/relman/crates/core/src/mocks/vcs.rs | 24 + .../relman/crates/core/src/mocks/workspace.rs | 53 + tools/relman/crates/core/src/ports.rs | 20 + tools/relman/crates/core/src/ports/driven.rs | 347 + tools/relman/crates/core/src/ports/driving.rs | 497 ++ tools/relman/crates/core/src/types.rs | 51 + tools/relman/crates/core/src/types/about.rs | 15 + tools/relman/crates/core/src/types/bump.rs | 119 + .../crates/core/src/types/bump_table.rs | 155 + .../crates/core/src/types/change_entry.rs | 111 + .../crates/core/src/types/change_kind.rs | 126 + .../relman/crates/core/src/types/changeset.rs | 781 +++ .../crates/core/src/types/consumed_ledger.rs | 282 + .../crates/core/src/types/crate_name.rs | 113 + .../relman/crates/core/src/types/cycle_id.rs | 94 + .../crates/core/src/types/cycle_status.rs | 598 ++ .../crates/core/src/types/description.rs | 59 + .../crates/core/src/types/publish_plan.rs | 60 + .../crates/core/src/types/release_options.rs | 53 + tools/relman/crates/core/src/types/section.rs | 140 + tools/relman/crates/core/src/types/slug.rs | 235 + tools/relman/crates/core/src/types/tag.rs | 203 + .../relman/crates/core/src/types/tag_plan.rs | 51 + tools/relman/crates/core/src/types/target.rs | 52 + tools/relman/crates/core/src/types/uid.rs | 157 + tools/relman/crates/core/src/types/version.rs | 92 + .../crates/core/src/types/workspace_path.rs | 106 + tools/relman/crates/domain/Cargo.toml | 18 + tools/relman/crates/domain/src/lib.rs | 10 + tools/relman/crates/domain/src/render.rs | 729 ++ tools/relman/crates/domain/src/services.rs | 15 + .../crates/domain/src/services/about.rs | 44 + .../crates/domain/src/services/apply_bump.rs | 217 + .../crates/domain/src/services/changelog.rs | 349 + .../domain/src/services/changeset_check.rs | 417 ++ .../crates/domain/src/services/changesets.rs | 763 ++ .../domain/src/services/release_artifacts.rs | 609 ++ .../crates/domain/src/services/versions.rs | 758 ++ tools/scripts/build-image.sh | 52 +- tools/scripts/check-matching-zebras.sh | 49 - tools/scripts/check-zaino-proto-heavy.rs | 25 +- tools/scripts/container-nextest-workspace.sh | 21 - .../scripts/container-test-retry-failures.sh | 37 - tools/scripts/container-test-save-failures.sh | 33 - tools/scripts/functions.sh | 13 - tools/scripts/get-ci-image-tag.sh | 16 +- tools/scripts/help.sh | 57 +- tools/scripts/helpers.sh | 19 - tools/scripts/init-podman-volumes.sh | 37 - tools/scripts/it/README.md | 78 + tools/scripts/it/changeset-check.it.sh | 283 + tools/scripts/mark-deployment.sh | 44 + tools/scripts/pipeline-fork-sandbox.sh | 129 + tools/scripts/sandbox-bless-ledger-check.sh | 95 + tools/scripts/sandbox-common.sh | 40 + tools/scripts/sandbox-cycle.sh | 154 + tools/scripts/sandbox-sentinel-check.sh | 56 + .../scripts}/test-container-permissions.sh | 0 tools/scripts/update-test-targets.sh | 66 - tools/scripts/validate-makefile-tasks.rs | 105 - tools/scripts/validate-test-targets.sh | 73 - tools/scripts/verify-all.sh | 38 +- tools/test-runner/Cargo.lock | 7 - tools/test-runner/Cargo.toml | 19 - tools/test-runner/src/bin/container-test.rs | 127 - tools/test-runner/src/bin/live-summary.rs | 276 - .../src/bin/check-code-duplication.rs | 9 +- .../src/bin/check-published-versions.rs | 2 +- tools/workbench/src/bin/get-zebra-git-ref.rs | 36 - tools/workbench/src/lib.rs | 83 - zainod-heights-from-validator-spec.md | 8 +- 528 files changed, 59124 insertions(+), 20080 deletions(-) delete mode 100644 .config/nextest.toml create mode 100644 .dupes-ignore.toml delete mode 100644 .env.testing-artifacts create mode 100644 .github/CODEOWNERS create mode 100644 .github/actions/cycle-ids/action.yml create mode 100644 .github/actions/setup-relman/action.yml create mode 100644 .github/workflows/backport-sentinel.yml create mode 100644 .github/workflows/blessing.yml create mode 100644 .github/workflows/changeset-check.yml create mode 100644 .github/workflows/changeset-rename.yml create mode 100644 .github/workflows/deployment-advance.yml create mode 100644 .github/workflows/deployment-signoff.yml create mode 100644 .github/workflows/hotfix-notice.yml create mode 100644 .github/workflows/live-tests.yaml create mode 100644 .github/workflows/rc-gate.yml create mode 100644 .github/workflows/release-pr-body.yml create mode 100644 .release/gate-suites.toml delete mode 100644 docs/adr/0001-zcashd-support-feature-gate.md delete mode 100644 docs/adr/0005-zcashd-support-default-off.md create mode 100644 docs/adr/0011-chain-head-subsystem-separation.md create mode 100644 docs/adr/0012-chain-store-subsystem-separation.md rename docs/{decision_records/release/periodic.md => adr/0015-periodic-release-flow.md} (99%) create mode 100644 docs/adr/0016-changeset-derived-release-pipeline.md create mode 100644 docs/adr/README.md create mode 100644 docs/example_configs/zainod-bench-mainnet-ephemeral.toml create mode 100644 docs/example_configs/zainod-bench-mainnet.toml create mode 100644 docs/perf.md create mode 100644 docs/release/changeset-format.md create mode 100644 docs/release/implementation.md create mode 100644 docs/release/pipeline.md create mode 100644 live-tests/CLAUDE.md delete mode 100644 live-tests/CONTEXT.md create mode 100644 live-tests/Cargo.lock create mode 100644 live-tests/Cargo.toml create mode 100644 live-tests/clientless/tests/finalisation_seam.rs delete mode 100644 live-tests/clientless/tests/json_server.rs delete mode 100644 live-tests/clientless/tests/the_pub_testnet_ironwood_boundary.rs delete mode 100644 live-tests/e2e/src/devtool.rs delete mode 100644 live-tests/e2e/tests/devtool.rs delete mode 100644 live-tests/e2e/tests/devtool_zcashd.rs delete mode 100644 live-tests/e2e/tests/test_vectors.rs create mode 100644 live-tests/e2e/tests/wallet_to_validator.rs delete mode 100644 live-tests/quick_test.txt delete mode 100644 live-tests/test_binaries/bins/.gitinclude delete mode 100755 live-tests/test_environment/entrypoint.sh create mode 100644 live-tests/zaino-testutils/src/finalised.rs create mode 100644 live-tests/zaino-testutils/src/hex.rs create mode 100644 live-tests/zaino-testutils/src/json.rs delete mode 100644 live-tests/zaino-testutils/src/legacy_parser/indexed_block.rs create mode 100644 live-tests/zaino-testutils/src/rpc.rs delete mode 100644 live-tests/zaino-testutils/src/validator_oracle.rs create mode 100644 packages/zaino-bench/Cargo.toml create mode 100644 packages/zaino-bench/src/chain.rs create mode 100644 packages/zaino-bench/src/concurrent.rs create mode 100644 packages/zaino-bench/src/error.rs create mode 100644 packages/zaino-bench/src/grpc_client.rs create mode 100644 packages/zaino-bench/src/main.rs create mode 100644 packages/zaino-bench/src/metrics.rs create mode 100644 packages/zaino-bench/src/serve.rs create mode 100644 packages/zaino-bench/src/stats.rs create mode 100644 packages/zaino-bench/src/sync.rs create mode 100644 packages/zaino-bench/usage.md create mode 100644 packages/zaino-chain-head-service/CHANGELOG.md create mode 100644 packages/zaino-chain-head-service/Cargo.toml create mode 100644 packages/zaino-chain-head-service/src/error.rs create mode 100644 packages/zaino-chain-head-service/src/lib.rs create mode 100644 packages/zaino-chain-head-service/src/metric_names.rs create mode 100644 packages/zaino-chain-head-service/src/service.rs create mode 100644 packages/zaino-chain-head-service/src/snapshot.rs create mode 100644 packages/zaino-chain-head-service/src/subscriber.rs create mode 100644 packages/zaino-chain-head-service/src/tests.rs create mode 100644 packages/zaino-chain-head-service/usage.md create mode 100644 packages/zaino-chain-head/CHANGELOG.md create mode 100644 packages/zaino-chain-head/Cargo.toml create mode 100644 packages/zaino-chain-head/src/block.rs create mode 100644 packages/zaino-chain-head/src/config.rs create mode 100644 packages/zaino-chain-head/src/error.rs create mode 100644 packages/zaino-chain-head/src/lib.rs create mode 100644 packages/zaino-chain-head/src/ports.rs create mode 100644 packages/zaino-chain-head/src/snapshot.rs create mode 100644 packages/zaino-chain-head/src/transparent.rs create mode 100644 packages/zaino-chain-head/usage.md create mode 100644 packages/zaino-chain-store-zainodb/CHANGELOG.md create mode 100644 packages/zaino-chain-store-zainodb/Cargo.toml create mode 100644 packages/zaino-chain-store-zainodb/src/adapter.rs create mode 100644 packages/zaino-chain-store-zainodb/src/adapter/error_map.rs create mode 100644 packages/zaino-chain-store-zainodb/src/adapter/from_domain.rs create mode 100644 packages/zaino-chain-store-zainodb/src/adapter/history.rs create mode 100644 packages/zaino-chain-store-zainodb/src/adapter/to_domain.rs create mode 100644 packages/zaino-chain-store-zainodb/src/config.rs create mode 100644 packages/zaino-chain-store-zainodb/src/conversion.rs rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src}/entry.rs (94%) create mode 100644 packages/zaino-chain-store-zainodb/src/error.rs create mode 100644 packages/zaino-chain-store-zainodb/src/golden.rs create mode 100644 packages/zaino-chain-store-zainodb/src/lib.rs create mode 100644 packages/zaino-chain-store-zainodb/src/metric_names.rs create mode 100644 packages/zaino-chain-store-zainodb/src/pool.rs rename packages/{zaino-state/src/chain_index/finalised_state.rs => zaino-chain-store-zainodb/src/store.rs} (61%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/CHANGELOG.md (100%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/capability.rs (63%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source.rs (77%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/db_schema_v1.txt (100%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/ephemeral.rs (63%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1.rs (85%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1/block_core.rs (74%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1/block_shielded.rs (83%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1/block_transparent.rs (75%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1/compact_block.rs (73%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1/indexed_block.rs (58%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1/read_core.rs (71%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1/transparent_address_history.rs (88%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1/tx_out_set_accumulator.rs (85%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1/validation.rs (89%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/finalised_source/v1/write_core.rs (76%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/migrations.rs (83%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/reader.rs (67%) rename packages/{zaino-state/src/chain_index/finalised_state => zaino-chain-store-zainodb/src/store}/router.rs (75%) create mode 100644 packages/zaino-chain-store-zainodb/src/stream.rs create mode 100644 packages/zaino-chain-store-zainodb/src/support.rs create mode 100644 packages/zaino-chain-store-zainodb/src/tests.rs create mode 100644 packages/zaino-chain-store-zainodb/src/tests/fake_validator.rs rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/finalised_state.rs (63%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/finalised_state/ephemeral.rs (60%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/finalised_state/migrations.rs (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/finalised_state/migrations/v1_0_to_v1_1.rs (73%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/finalised_state/migrations/v1_1_to_v1_2.rs (86%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/finalised_state/migrations/v1_2_to_v1_3.rs (65%) create mode 100644 packages/zaino-chain-store-zainodb/src/tests/finalised_state/ports.rs rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/finalised_state/v1.rs (80%) create mode 100644 packages/zaino-chain-store-zainodb/src/tests/fixtures.rs create mode 100644 packages/zaino-chain-store-zainodb/src/tests/vectors.rs rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/vectors/faucet_data.json (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/vectors/recipient_data.json (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/vectors/tree_roots.dat (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/vectors/tree_states.dat (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/vectors/v1_test_db/regtest/v1/data.mdb (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/tests/vectors/zcash_blocks.dat (100%) create mode 100644 packages/zaino-chain-store-zainodb/src/types.rs rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/block_context.rs (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/db.rs (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/db/address.rs (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/db/block.rs (94%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/db/commitment.rs (93%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/db/legacy.rs (98%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/db/metadata.rs (70%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/db/primitives.rs (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/db/shielded.rs (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/db/transaction.rs (100%) rename packages/{zaino-state/src/chain_index/tests/types.rs => zaino-chain-store-zainodb/src/types/fixtures.rs} (92%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/helpers.rs (94%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/primitives.rs (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/primitives/block_index.rs (94%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/primitives/chain_work.rs (100%) rename packages/{zaino-state/src/chain_index => zaino-chain-store-zainodb/src}/types/primitives/compact_difficulty.rs (100%) create mode 100644 packages/zaino-chain-store-zainodb/usage.md create mode 100644 packages/zaino-chain-store/CHANGELOG.md create mode 100644 packages/zaino-chain-store/Cargo.toml create mode 100644 packages/zaino-chain-store/src/block.rs create mode 100644 packages/zaino-chain-store/src/capability.rs create mode 100644 packages/zaino-chain-store/src/config.rs create mode 100644 packages/zaino-chain-store/src/error.rs create mode 100644 packages/zaino-chain-store/src/lib.rs create mode 100644 packages/zaino-chain-store/src/output.rs create mode 100644 packages/zaino-chain-store/src/ports.rs create mode 100644 packages/zaino-chain-store/src/transparent.rs create mode 100644 packages/zaino-chain-store/src/txout_set.rs create mode 100644 packages/zaino-chain-store/usage.md create mode 100644 packages/zaino-encoding/CHANGELOG.md create mode 100644 packages/zaino-encoding/Cargo.toml rename packages/{zaino-state/src/chain_index/encoding.rs => zaino-encoding/src/lib.rs} (100%) create mode 100644 packages/zaino-encoding/usage.md create mode 100644 packages/zaino-primitives/src/types/block_tx_position.rs create mode 100644 packages/zaino-primitives/src/types/chain_state_epoch.rs create mode 100644 packages/zaino-primitives/src/types/equihash_solution.rs create mode 100644 packages/zaino-primitives/src/types/mempool_info.rs create mode 100644 packages/zaino-primitives/src/types/outpoint.rs create mode 100644 packages/zaino-proto/lightwallet-protocol/README.md create mode 100644 packages/zaino-source-macros/CHANGELOG.md create mode 100644 packages/zaino-source-macros/Cargo.toml create mode 100644 packages/zaino-source-macros/src/lib.rs rename packages/zaino-source/src/{resilient.rs => validator_client.rs} (56%) create mode 100644 packages/zaino-state/src/chain_index/chain_head.rs create mode 100644 packages/zaino-state/src/chain_index/chain_store.rs create mode 100644 packages/zaino-state/src/chain_index/chain_store/driving.rs create mode 100644 packages/zaino-state/src/chain_index/chain_store/reading.rs delete mode 100644 packages/zaino-state/src/chain_index/non_finalised_state.rs create mode 100644 packages/zaino-state/src/chain_index/tests/chain_head.rs create mode 100644 packages/zaino-state/src/chain_index/tests/golden.rs delete mode 100644 packages/zaino-state/src/chain_index/tests/non_finalised_state.rs delete mode 100644 packages/zaino-state/src/chain_index/types/wire.rs create mode 100644 packages/zaino-state/src/chain_index/wire_types.rs create mode 100644 relman.toml create mode 100644 tools/relman/Cargo.lock create mode 100644 tools/relman/Cargo.toml create mode 100644 tools/relman/README.md create mode 100644 tools/relman/crates/adapters/Cargo.toml create mode 100644 tools/relman/crates/adapters/src/cargo_metadata_workspace.rs create mode 100644 tools/relman/crates/adapters/src/fs_changelog_store.rs create mode 100644 tools/relman/crates/adapters/src/fs_changeset_store.rs create mode 100644 tools/relman/crates/adapters/src/fs_consumed_ledger.rs create mode 100644 tools/relman/crates/adapters/src/git_vcs.rs create mode 100644 tools/relman/crates/adapters/src/lib.rs create mode 100644 tools/relman/crates/adapters/src/random_slug_source.rs create mode 100644 tools/relman/crates/adapters/src/random_uid_source.rs create mode 100644 tools/relman/crates/adapters/src/toml_edit_manifest_editor.rs create mode 100644 tools/relman/crates/app/Cargo.toml create mode 100644 tools/relman/crates/app/src/main.rs create mode 100644 tools/relman/crates/app/tests/cli.rs create mode 100644 tools/relman/crates/cli/Cargo.toml create mode 100644 tools/relman/crates/cli/src/app.rs create mode 100644 tools/relman/crates/cli/src/commands.rs create mode 100644 tools/relman/crates/cli/src/commands/about.rs create mode 100644 tools/relman/crates/cli/src/commands/bump.rs create mode 100644 tools/relman/crates/cli/src/commands/changelog.rs create mode 100644 tools/relman/crates/cli/src/commands/changeset.rs create mode 100644 tools/relman/crates/cli/src/commands/changeset/check.rs create mode 100644 tools/relman/crates/cli/src/commands/changeset/clear.rs create mode 100644 tools/relman/crates/cli/src/commands/changeset/consume.rs create mode 100644 tools/relman/crates/cli/src/commands/changeset/new.rs create mode 100644 tools/relman/crates/cli/src/commands/changeset/rename.rs create mode 100644 tools/relman/crates/cli/src/commands/pr_body.rs create mode 100644 tools/relman/crates/cli/src/commands/publish_plan.rs create mode 100644 tools/relman/crates/cli/src/commands/tags.rs create mode 100644 tools/relman/crates/cli/src/commands/versions.rs create mode 100644 tools/relman/crates/cli/src/context.rs create mode 100644 tools/relman/crates/cli/src/format.rs create mode 100644 tools/relman/crates/cli/src/lib.rs create mode 100644 tools/relman/crates/cli/src/warn.rs create mode 100644 tools/relman/crates/config/Cargo.toml create mode 100644 tools/relman/crates/config/src/config.rs create mode 100644 tools/relman/crates/config/src/error.rs create mode 100644 tools/relman/crates/config/src/lib.rs create mode 100644 tools/relman/crates/config/src/load.rs create mode 100644 tools/relman/crates/core/Cargo.toml create mode 100644 tools/relman/crates/core/src/lib.rs create mode 100644 tools/relman/crates/core/src/mocks.rs create mode 100644 tools/relman/crates/core/src/mocks/changelog_store.rs create mode 100644 tools/relman/crates/core/src/mocks/changeset_store.rs create mode 100644 tools/relman/crates/core/src/mocks/clock.rs create mode 100644 tools/relman/crates/core/src/mocks/consumed_ledger_store.rs create mode 100644 tools/relman/crates/core/src/mocks/fixtures.rs create mode 100644 tools/relman/crates/core/src/mocks/manifest_editor.rs create mode 100644 tools/relman/crates/core/src/mocks/slug_source.rs create mode 100644 tools/relman/crates/core/src/mocks/uid_source.rs create mode 100644 tools/relman/crates/core/src/mocks/vcs.rs create mode 100644 tools/relman/crates/core/src/mocks/workspace.rs create mode 100644 tools/relman/crates/core/src/ports.rs create mode 100644 tools/relman/crates/core/src/ports/driven.rs create mode 100644 tools/relman/crates/core/src/ports/driving.rs create mode 100644 tools/relman/crates/core/src/types.rs create mode 100644 tools/relman/crates/core/src/types/about.rs create mode 100644 tools/relman/crates/core/src/types/bump.rs create mode 100644 tools/relman/crates/core/src/types/bump_table.rs create mode 100644 tools/relman/crates/core/src/types/change_entry.rs create mode 100644 tools/relman/crates/core/src/types/change_kind.rs create mode 100644 tools/relman/crates/core/src/types/changeset.rs create mode 100644 tools/relman/crates/core/src/types/consumed_ledger.rs create mode 100644 tools/relman/crates/core/src/types/crate_name.rs create mode 100644 tools/relman/crates/core/src/types/cycle_id.rs create mode 100644 tools/relman/crates/core/src/types/cycle_status.rs create mode 100644 tools/relman/crates/core/src/types/description.rs create mode 100644 tools/relman/crates/core/src/types/publish_plan.rs create mode 100644 tools/relman/crates/core/src/types/release_options.rs create mode 100644 tools/relman/crates/core/src/types/section.rs create mode 100644 tools/relman/crates/core/src/types/slug.rs create mode 100644 tools/relman/crates/core/src/types/tag.rs create mode 100644 tools/relman/crates/core/src/types/tag_plan.rs create mode 100644 tools/relman/crates/core/src/types/target.rs create mode 100644 tools/relman/crates/core/src/types/uid.rs create mode 100644 tools/relman/crates/core/src/types/version.rs create mode 100644 tools/relman/crates/core/src/types/workspace_path.rs create mode 100644 tools/relman/crates/domain/Cargo.toml create mode 100644 tools/relman/crates/domain/src/lib.rs create mode 100644 tools/relman/crates/domain/src/render.rs create mode 100644 tools/relman/crates/domain/src/services.rs create mode 100644 tools/relman/crates/domain/src/services/about.rs create mode 100644 tools/relman/crates/domain/src/services/apply_bump.rs create mode 100644 tools/relman/crates/domain/src/services/changelog.rs create mode 100644 tools/relman/crates/domain/src/services/changeset_check.rs create mode 100644 tools/relman/crates/domain/src/services/changesets.rs create mode 100644 tools/relman/crates/domain/src/services/release_artifacts.rs create mode 100644 tools/relman/crates/domain/src/services/versions.rs delete mode 100755 tools/scripts/check-matching-zebras.sh delete mode 100644 tools/scripts/container-nextest-workspace.sh delete mode 100755 tools/scripts/container-test-retry-failures.sh delete mode 100755 tools/scripts/container-test-save-failures.sh delete mode 100755 tools/scripts/init-podman-volumes.sh create mode 100644 tools/scripts/it/README.md create mode 100755 tools/scripts/it/changeset-check.it.sh create mode 100755 tools/scripts/mark-deployment.sh create mode 100755 tools/scripts/pipeline-fork-sandbox.sh create mode 100755 tools/scripts/sandbox-bless-ledger-check.sh create mode 100644 tools/scripts/sandbox-common.sh create mode 100755 tools/scripts/sandbox-cycle.sh create mode 100755 tools/scripts/sandbox-sentinel-check.sh rename {live-tests/test_environment => tools/scripts}/test-container-permissions.sh (100%) delete mode 100755 tools/scripts/update-test-targets.sh delete mode 100644 tools/scripts/validate-makefile-tasks.rs delete mode 100755 tools/scripts/validate-test-targets.sh delete mode 100644 tools/test-runner/Cargo.lock delete mode 100644 tools/test-runner/Cargo.toml delete mode 100644 tools/test-runner/src/bin/container-test.rs delete mode 100644 tools/test-runner/src/bin/live-summary.rs delete mode 100644 tools/workbench/src/bin/get-zebra-git-ref.rs diff --git a/.config/nextest.toml b/.config/nextest.toml deleted file mode 100644 index 79608e2f..00000000 --- a/.config/nextest.toml +++ /dev/null @@ -1,56 +0,0 @@ -# Nextest configuration for the reunified Zaino workspace. -# -# The production crates and the live-test crates (e2e, clientless, -# zaino-testutils) are all members of one workspace, so nextest reads only this -# root config. It was merged from the former per-workspace configs -# (live-tests/.config and live-tests/e2e/.config, now deleted); see -# docs/adr/0002, docs/adr/0003, docs/adr/0004. -# -# Profiles: -# default bare `cargo nextest run` (production units), the local `makers test` -# runs, AND the CI test job (ci.yml passes `--profile default`). -# Live-test tuning is scoped to the live packages via overrides; -# `[profile.default.junit]` emits junit.xml for CI reporting. -# targets the canonical test-target inventory (minus testnet/client_rpcs) -# consumed by tools/scripts/{update,validate}-test-targets.sh. This is -# NOT the CI test run -- that is `default`. - -[profile.default] -# Minimal by design: the production unit-test run (bare `cargo nextest run`, -# which excludes the live crates via `default-members`) keeps full `num-cpus` -# parallelism, no slow-timeout, and retries = 0. All live-test tuning is scoped -# to the live packages below so it never touches the production path — even -# though `makers live-*` also runs through this profile. -fail-fast = false - -# JUnit output for CI reporting (the CI test job runs this profile). Harmless -# locally — writes target/nextest/default/junit.xml. -[profile.default.junit] -path = "junit.xml" - -# Each live test launches a full validator; this group caps concurrent live -# tests (hence concurrent validators) to 6 without throttling production unit -# tests, which are not group members and run at full `num-cpus`. -[test-groups] -live-validators = { max-threads = 6 } - -# Live-package tuning: capped concurrency (via the group) and a slow-timeout for -# validator startup. Scoped to the live packages so production unit tests keep -# no slow-timeout. Retries are 0 everywhere: a flaky live test is a signal to -# fix, not to absorb. -[[profile.default.overrides]] -filter = 'package(e2e) | package(clientless)' -test-group = "live-validators" -slow-timeout = { period = "60s", terminate-after = "10" } -retries = 0 - -# The canonical test-target inventory consumed by the update/validate-test-targets -# scripts via `cargo nextest list` -- NOT the CI test run (that is `default`). -# Listing only enumerates tests, so this profile needs nothing but the filter: -# run-time keys (test-threads, retries, slow-timeout, junit) would be inert here. -# Enumerates the full suite minus the testnet and client_rpcs targets. -[profile.targets] -default-filter = """ - not test(testnet) - & not test(client_rpcs) -""" diff --git a/.dockerignore b/.dockerignore index 81119477..a0b32dc1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,15 @@ target/ **/target/ **/*.rs.bk +# Nix build outputs (dangling store symlinks; must never enter the context) +result +result-* + +# direnv cache: filenames embed a per-eval env hash, so leaving this in the +# context rotates the content-addressed image tag on every `direnv reload`. +.direnv/ +.envrc + # VCS / CI .git/ .gitignore diff --git a/.dupes-ignore.toml b/.dupes-ignore.toml new file mode 100644 index 00000000..9e4ce979 --- /dev/null +++ b/.dupes-ignore.toml @@ -0,0 +1,21 @@ +# Duplicate groups the check-code-duplication lint has been told to allow. +# +# An entry here is a claim that a group is irreducible, not that it is +# unimportant. Each one names why sharing the code would make things worse, so +# a later reader can re-litigate it rather than inherit it. + +[[ignore]] +fingerprint = "a56301cbdb5a82de" +members = [ + "::fmt", + "::fmt", +] +reason = """ +Two unrelated enums whose Display impls are each a match over their own \ +variants mapping to their own strings. The shape is shared; nothing else is. \ +Factoring it would mean a generic variant-to-string helper that both enums \ +route through, which adds indirection to two eight-line functions and couples \ +a chain-store capability to an RPC chain-tip status for no reason beyond their \ +both being enums. They also live in crates that do not and should not depend \ +on each other. +""" diff --git a/.env.testing-artifacts b/.env.testing-artifacts deleted file mode 100644 index c17c9872..00000000 --- a/.env.testing-artifacts +++ /dev/null @@ -1,19 +0,0 @@ -# RUST_VERSION is intentionally NOT set here. The canonical source is -# rust-toolchain.toml's `channel`; consumers derive it via -# the workbench get-rust-version bin. - -# zcashd Git tag (https://github.com/zcash/zcash/releases) -ZCASH_VERSION=6.20.0 - -ZEBRA_VERSION=6.3.0 - -# zcash-devtool git ref (https://github.com/zingolabs/zcash-devtool) — -# the wallet client driven by wallet-tests via zcash_local_net, built -# with `--features regtest_support`. This may be a branch (moving) or a -# pinned SHA/tag: the image tag embeds the *resolved commit SHA* -# (get-ci-image-tag.sh → resolve_devtool_rev), so a branch HEAD advancing -# changes the tag automatically and the rebuild always bakes the current -# binary — no manual SHA bump needed. Pin a tag here only to freeze it. -# Pinned to the NU6.3/Ironwood support commit: tip of -# `ironwood-valar-portables`, under review as zcash/zcash-devtool#208. -DEVTOOL_VERSION=2efc8e9c690c6b4a844d6bb0a49bc66ba6fcbb14 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..3cafbcd7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,22 @@ +# CODEOWNERS — drives automatic review requests on pull requests. +# +# Listing an owner for a path makes GitHub REQUEST that owner's review when a PR +# touches it. That is all this file does. It does NOT by itself require approval: +# the approval COUNTS are branch-protection rules, configured separately per the +# release ADR — +# * 1 CODEOWNER approval to merge into `dev` +# * 2 CODEOWNER approvals to merge into `stable` +# (see docs/release/pipeline.md § "Branching and approvals"). +# +# The team handles below are PLACEHOLDERS. Replace every `@zingolabs/PLACEHOLDER-*` +# with a real GitHub team (or user) slug before this file is relied upon; an +# unresolvable owner silently requests no one. + +# Default owner for everything in the repo. +* @zingolabs/PLACEHOLDER-maintainers + +# Example per-path ownership. Uncomment and point at real teams as the review +# structure firms up — more specific paths win over the default above. +# /tools/relman/ @zingolabs/PLACEHOLDER-release-tooling +# /.github/workflows/ @zingolabs/PLACEHOLDER-ci +# /docs/release/ @zingolabs/PLACEHOLDER-maintainers diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index f2a1fe79..505cd0ac 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -70,7 +70,7 @@ body: description: | Please include: - Zaino version or commit hash - - Backend version (zebra/zcashd) + - Backend version (zebra) - OS and platform placeholder: | Zaino: v0.x.x or commit abc123 diff --git a/.github/actions/cycle-ids/action.yml b/.github/actions/cycle-ids/action.yml new file mode 100644 index 00000000..3201a30b --- /dev/null +++ b/.github/actions/cycle-ids/action.yml @@ -0,0 +1,63 @@ +name: Cycle ids +description: >- + Derive the release-cycle identity from the existing git tags: the open + cycle N (the one a new rc or a blessing belongs to) and the next prerelease + number M within it. The checkout must have fetched all tags (fetch-depth: 0). + Two tag shapes carry the numbers: `cycle-` (final, blessed) and + `cycle--rc.` (deployment prerelease). Date-style ids such as + `cycle-2026-08-15` never match and are ignored. + +outputs: + open: + description: "The open cycle N: 1 with no cycle tags, hi+1 when `cycle-` is final, else hi." + value: ${{ steps.ids.outputs.open }} + next_rc: + description: "The next prerelease number M within the open cycle (max existing + 1, or 1)." + value: ${{ steps.ids.outputs.next_rc }} + last_rc_tag: + description: "The highest existing `cycle--rc.` tag of the open cycle, or empty." + value: ${{ steps.ids.outputs.last_rc_tag }} + +runs: + using: composite + steps: + - name: Derive cycle ids from tags + id: ids + shell: bash + run: | + set -euo pipefail + + # highest_cycle(): the max integer N seen across BOTH tag shapes, or + # empty when no cycle tags exist. + highest_cycle() { + git tag --list 'cycle-*' \ + | sed -n 's/^cycle-\([0-9][0-9]*\)\(-rc\.[0-9][0-9]*\)\{0,1\}$/\1/p' \ + | sort -n | tail -1 + } + # open_cycle(): the cycle a NEW rc (or a blessing) belongs to. + open_cycle() { + hi="$(highest_cycle)" + if [ -z "$hi" ]; then + echo 1 + elif git rev-parse -q --verify "refs/tags/cycle-${hi}" >/dev/null; then + echo $((hi + 1)) + else + echo "$hi" + fi + } + # last_rc(N): the highest existing M of `cycle--rc.`, or empty. + last_rc() { + git tag --list "cycle-${1}-rc.*" \ + | sed -n "s/^cycle-${1}-rc\.\([0-9][0-9]*\)$/\1/p" \ + | sort -n | tail -1 + } + + N="$(open_cycle)" + last="$(last_rc "$N")" + if [ -z "$last" ]; then M=1; last_tag=""; else M=$((last + 1)); last_tag="cycle-${N}-rc.${last}"; fi + echo "open cycle N=$N, next rc M=$M, last rc tag=${last_tag:-none}" + { + echo "open=$N" + echo "next_rc=$M" + echo "last_rc_tag=$last_tag" + } >> "$GITHUB_OUTPUT" diff --git a/.github/actions/setup-relman/action.yml b/.github/actions/setup-relman/action.yml new file mode 100644 index 00000000..015a5979 --- /dev/null +++ b/.github/actions/setup-relman/action.yml @@ -0,0 +1,52 @@ +name: Set up relman +description: >- + Put the `relman` release-manager CLI on PATH without assuming cargo is + available. Prefers an already-installed binary, then a prebuilt one under + tools/relman/target, and only falls back to building with cargo. This lets + changeset-only checks (which need no cargo) run in a minimal container while + real CI (which has cargo) still builds from source. + +runs: + using: composite + steps: + - name: Probe for cargo + id: probe + shell: bash + run: | + if command -v cargo >/dev/null 2>&1; then echo "has_cargo=true"; else echo "has_cargo=false"; fi >> "${GITHUB_OUTPUT}" + + # Only the cargo fallback below benefits; a runner without cargo (the + # minimal changeset-check container) skips the cache entirely. + - name: Cache the relman build + if: steps.probe.outputs.has_cargo == 'true' + uses: Swatinem/rust-cache@v2 + with: + workspaces: tools/relman + + - name: Resolve relman onto PATH + shell: bash + run: | + set -euo pipefail + + # Candidate prebuilt binaries, in preference order. A static musl build + # is what the local integration harness stages; the plain release path + # is what a prior `cargo build` in this job would have produced. + musl="tools/relman/target/x86_64-unknown-linux-musl/release/relman" + release="tools/relman/target/release/relman" + + if command -v relman >/dev/null 2>&1; then + echo "setup-relman: using relman already on PATH ($(command -v relman))" + elif [ -x "${musl}" ]; then + echo "setup-relman: using prebuilt musl binary ${musl}" + echo "${GITHUB_WORKSPACE}/$(dirname "${musl}")" >> "${GITHUB_PATH}" + elif [ -x "${release}" ]; then + echo "setup-relman: using prebuilt binary ${release}" + echo "${GITHUB_WORKSPACE}/$(dirname "${release}")" >> "${GITHUB_PATH}" + elif command -v cargo >/dev/null 2>&1; then + echo "setup-relman: no prebuilt binary; building with cargo" + cargo build --release --manifest-path tools/relman/Cargo.toml + echo "${GITHUB_WORKSPACE}/tools/relman/target/release" >> "${GITHUB_PATH}" + else + echo "::error::setup-relman: relman is not on PATH, no prebuilt binary was found at ${musl} or ${release}, and cargo is unavailable to build it." >&2 + exit 1 + fi diff --git a/.github/workflows/auto-tag-rc.yml b/.github/workflows/auto-tag-rc.yml index 910ef15c..922060d9 100644 --- a/.github/workflows/auto-tag-rc.yml +++ b/.github/workflows/auto-tag-rc.yml @@ -1,3 +1,7 @@ +# LEGACY tag producer for the pre-cutover release protocol (rc/ +# branches). It stays live until the repo variable RELMAN_PIPELINE_ACTIVE is +# set to true, at which point the release pipeline (rc-gate / blessing) owns +# every tag and this workflow goes dormant. Delete it after the cutover. name: Auto RC Tag on: @@ -8,7 +12,7 @@ on: jobs: auto-tag-rc: - if: github.event.pull_request.merged == true + if: github.event.pull_request.merged == true && vars.RELMAN_PIPELINE_ACTIVE != 'true' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/backport-sentinel.yml b/.github/workflows/backport-sentinel.yml new file mode 100644 index 00000000..fc38129f --- /dev/null +++ b/.github/workflows/backport-sentinel.yml @@ -0,0 +1,198 @@ +# ============================================================================ +# BACKPORT SENTINEL — carry `stable` back into `dev`. +# +# INERT until the repo variable RELMAN_PIPELINE_ACTIVE=true. Activated only at +# the coordinated pipeline cutover; until then this workflow does nothing. +# +# Guarantees no commit that reached `stable` is ever stranded outside `dev`: +# the release (blessing) commit — version bumps, CHANGELOGs, and `.changesets/` +# consumed marks — plus any emergency hotfix pushed straight to `stable`. It +# fires on every push to `stable` and again when a sync PR merges; when +# `stable \ dev` is non-empty it opens an ordinary PR into `dev` from a +# disposable `sync/stable-to-dev` branch cut at stable's tip, so any conflict +# resolution happens off to the side and never blocks `stable`. While a sync PR +# is open, each new `stable` push is MERGED into its branch (never force-pushed +# over it), so in-flight conflict resolution survives and the PR always carries +# the whole of `stable \ dev`. This is the reverse of the forward desync +# sentinel (the standing release PR). See docs/release/pipeline.md +# § "Reverse: the Backport Sentinel". +# +# Auto-merging that PR is OPT-IN (repo var RELMAN_BACKPORT_AUTOMERGE=true). By +# default the sync PR is opened for a maintainer to review and merge; enabling +# auto-merge lets it self-dissolve once clean + gate-green. +# +# No self-trigger: it pushes `sync/stable-to-dev` and opens a PR into `dev` — +# neither writes to `stable` — so it never re-fires itself from a push. +# +# Requires the GitHub App (RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY): a +# GITHUB_TOKEN cannot create pull requests (setting-dependent) and any PR it +# opens does not trigger the dev-gate checks. An App with pull_requests:write +# is subject to neither limit. The sync PR is therefore App-authored, which is +# how `changeset-check` recognises it (author type Bot + the `backport` label +# + the branch name), so the label is applied before the PR is left alone. +# ============================================================================ + +name: Backport sentinel + +on: + push: + branches: [stable] + # A merged sync PR may have been cut before a later `stable` push; re-check + # the set difference once it lands so nothing stays stranded until the next + # release. + pull_request: + types: [closed] + branches: [dev] + workflow_dispatch: + +# Least privilege: create/update the disposable sync branch, and open the PR. +permissions: + contents: write + pull-requests: write + +jobs: + backport: + name: Carry stable into dev + if: >- + vars.RELMAN_PIPELINE_ACTIVE == 'true' && + (github.event_name != 'pull_request' || + (github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'sync/stable-to-dev')) + runs-on: ubuntu-latest + steps: + - name: Mint GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v7 + with: + ref: stable + # Full history and every branch, so `origin/dev..origin/stable` is + # computable without a separate fetch and the sync branch carries real + # ancestry. Token so `git push` (below) authenticates as the App. + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Determine whether stable is ahead of dev + id: delta + run: | + set -euo pipefail + ahead="$(git rev-list --count origin/dev..origin/stable)" + echo "stable is $ahead commit(s) ahead of dev" + echo "ahead=$ahead" >> "$GITHUB_OUTPUT" + + # Ensure exactly one sync PR is open into `dev`, carrying all of + # `stable \ dev`. With no open PR, cut a fresh snapshot of stable's tip. + # With one open, MERGE stable's tip into its branch: a force-push would + # discard a human's conflict resolution, and leaving the branch untouched + # would strand every later `stable` push until the PR merged. Auto-merge is + # OPT-IN (RELMAN_BACKPORT_AUTOMERGE=true) and re-asserted idempotently. + - name: Ensure a backport PR (auto-merge opt-in) + if: steps.delta.outputs.ahead != '0' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + # Opt-in accelerator: unset/≠'true' leaves the PR for a maintainer to + # merge. Enable later, once the backport behaviour has been observed — + # the next `stable` push re-asserts auto-merge on the standing PR. + AUTOMERGE: ${{ vars.RELMAN_BACKPORT_AUTOMERGE }} + run: | + set -euo pipefail + branch="sync/stable-to-dev" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + num="$(gh pr list --base dev --head "$branch" --state open \ + --json number --jq '.[0].number // empty')" + + if [ -z "$num" ]; then + # No open sync PR: cut a fresh snapshot of stable's tip and open one. + # Force is safe — with no open PR there is no resolution to lose. + git branch -f "$branch" origin/stable + git push -f origin "$branch" + + # printf, not a heredoc: an unindented heredoc body would break out of + # this YAML block scalar. SC2016: the backticks are literal markdown + # code-spans, not command substitution — single quotes are deliberate. + # shellcheck disable=SC2016 + { + printf '%s\n' 'Automated backport: `stable` is ahead of `dev`, so this carries the release' + printf '%s\n' 'commit (version bumps, CHANGELOGs, `.changesets/` consumed marks) and any' + printf '%s\n\n' 'straight-to-`stable` hotfix back into `dev`, keeping the lineage append-only.' + printf '%s\n' "From \`dev\`'s point of view this is an ordinary feature PR. If it conflicts," + printf '%s\n' "resolve on \`$branch\` (never on \`stable\`). It is exempt from the dev-gate" + printf '%s\n\n' 'changeset check by design — a mechanical backport introduces no new changeset.' + printf '%s\n\n' 'Commits being carried back:' + printf '%s\n' '```' + git log --oneline origin/dev..origin/stable + printf '%s\n\n' '```' + printf '%s\n\n' 'Merge with a **merge commit** (not squash or rebase): only a true merge empties `stable \ dev` and stops the sentinel re-opening this PR.' + printf '%s\n' 'See docs/release/pipeline.md § "Reverse: the Backport Sentinel".' + } > body.md + + # The `backport` label is one of the three signals changeset-check + # and changeset-rename use to exempt this PR, and only labels present + # on the `opened` event count — a label added afterwards misses that + # first run. So the PR is created WITH its labels; the unlabelled + # fallback covers a fresh repo where the labels do not exist yet. + if num="$(gh pr create --base dev --head "$branch" \ + --title "sync: backport stable → dev" --body-file body.md \ + --label automated --label backport \ + | sed 's#.*/##')"; then + : + else + echo "::warning::labels 'automated'/'backport' not applied (create them once on the repo); the dev-gate will run on the sync PR." + num="$(gh pr create --base dev --head "$branch" \ + --title "sync: backport stable → dev" --body-file body.md \ + | sed 's#.*/##')" + fi + echo "Opened backport PR #$num" + else + # A sync PR is open: bring its branch up to stable's tip by merging, + # so the PR carries this push too and any resolution already on the + # branch is kept. A merge conflict here is left for the human on the + # branch — the PR is still the signal, and its body lists the delta. + git fetch --no-tags --quiet origin "$branch" + git checkout -q -B "$branch" "origin/$branch" + if git merge-base --is-ancestor origin/stable HEAD; then + echo "Backport PR #$num already carries stable's tip; branch untouched." + elif git merge --no-edit origin/stable; then + git push origin "$branch" + echo "Merged stable's tip into $branch; backport PR #$num now carries it." + else + git merge --abort + echo "::warning::stable's tip conflicts with the open backport PR #$num's branch; resolve on $branch (merge origin/stable into it) so the PR carries the newest stable." + fi + fi + + # Auto-merge is OPT-IN. By default the sync PR is opened/kept open for a + # maintainer to review and merge — the sentinel's guarantee (the PR + # exists as the desync signal, the dev-gate runs on the merged result) + # is already met. Set RELMAN_BACKPORT_AUTOMERGE=true to let it merge on + # its own once the behaviour has been observed in practice. + if [ "${AUTOMERGE:-}" != "true" ]; then + echo "::notice::Auto-merge disabled (RELMAN_BACKPORT_AUTOMERGE != 'true'); leaving #$num for a maintainer to merge." + exit 0 + fi + + # Already enabled? Nothing to do. + if [ "$(gh pr view "$num" --json autoMergeRequest --jq '.autoMergeRequest != null')" = "true" ]; then + echo "auto-merge already enabled on #$num" + exit 0 + fi + + # (Re-)assert auto-merge with a MERGE COMMIT (never squash/rebase: only a + # true merge empties `stable \ dev` and stops this sentinel re-firing). + # Retry past GitHub's post-create mergeability race ("not mergeable yet"). + for attempt in 1 2 3 4 5; do + if gh pr merge "$num" --auto --merge; then + echo "auto-merge enabled on #$num (attempt $attempt)" + exit 0 + fi + echo "auto-merge enable attempt $attempt failed (mergeability still computing?); retrying..." + sleep 6 + done + echo "::warning::Could not enable auto-merge on #$num after retries; leaving it for a manual merge." diff --git a/.github/workflows/blessing.yml b/.github/workflows/blessing.yml new file mode 100644 index 00000000..3402dac7 --- /dev/null +++ b/.github/workflows/blessing.yml @@ -0,0 +1,351 @@ +# ============================================================================ +# BLESSING — release on merge to `stable`. PERFORMS IRREVERSIBLE crates.io +# PUBLISHES. +# +# INERT until the repo variable RELMAN_PIPELINE_ACTIVE=true. Activated only at +# the coordinated pipeline cutover; until then this workflow does nothing. +# +# WARNING: the publish step uploads to crates.io, which CANNOT be undone. This +# workflow stays dormant until RELMAN_PIPELINE_ACTIVE=true AND it requires: +# - a GitHub App (RELEASE_APP_ID + RELEASE_APP_PRIVATE_KEY secrets) installed +# with `contents: write` and on the `stable` branch-protection bypass list, +# to push the release commit + tags to protected `stable` +# - secrets.CARGO_REGISTRY_TOKEN crates.io publish credential +# Validate on a dry run / test crate before the first real bless. `stable` +# already exists and is pushed by the current live release process — the +# RELMAN_PIPELINE_ACTIVE gate is exactly what keeps this from firing on it +# before cutover. +# +# PROVENANCE: a release is blessed only when the push to `stable` is the merge +# of the standing release PR (`release-ready` -> `stable`). Any other push — +# an emergency hotfix pushed straight to `stable`, or this workflow's own +# release commit — is recorded by the backport sentinel but never released +# from here, so content that skipped the rc-gate and the deployment gate cannot +# reach crates.io. +# +# See docs/release/pipeline.md § "Blessing" and +# implementation.md (bump / changelog / tags / publish-plan responsibilities). +# ============================================================================ + +name: Blessing (release on stable) + +on: + push: + branches: [stable] + +# Committing the release + tags to protected `stable` uses the GitHub App token +# minted below (not GITHUB_TOKEN, which cannot push a protected branch). +permissions: + contents: write + +jobs: + guard: + name: Check release provenance + # Master safety switch. Every job in every pipeline workflow carries this + # gate so these files can land dormant. + if: vars.RELMAN_PIPELINE_ACTIVE == 'true' + runs-on: ubuntu-latest + outputs: + proceed: ${{ steps.provenance.outputs.proceed }} + steps: + - name: Mint GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + # Two identity checks, neither of which a commit message can forge: + # 1. self-trigger: the pusher is this pipeline's own App (the release + # commit it pushed below), so there is nothing new to release; + # 2. provenance: the pushed commit is the merge of a pull request from + # `release-ready` into `stable` — the standing release PR — so the + # content cleared every gate before it arrived here. + - name: Decide whether this push is a release to bless + id: provenance + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ACTOR: ${{ github.actor }} + APP_LOGIN: ${{ steps.app-token.outputs.app-slug }}[bot] + SHA: ${{ github.sha }} + run: | + set -euo pipefail + if [ "${ACTOR}" = "${APP_LOGIN}" ]; then + echo "::notice::push to stable by ${APP_LOGIN} is this pipeline's own release commit; nothing to bless." + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + release_prs="$(gh api "repos/${{ github.repository }}/commits/${SHA}/pulls" \ + --jq '[.[] | select(.merged_at != null and .base.ref == "stable" and .head.ref == "release-ready")] | length')" + if [ "${release_prs:-0}" -ge 1 ]; then + echo "stable ${SHA:0:9} is the merge of the release PR (release-ready -> stable); blessing." + echo "proceed=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::stable ${SHA:0:9} did not arrive through the release PR (release-ready -> stable); it is NOT blessed. The backport sentinel carries it into dev; release it through the normal rc-gate -> deployment -> release-ready path." + echo "proceed=false" >> "$GITHUB_OUTPUT" + fi + + bless: + name: Bump, tag, publish + needs: guard + if: needs.guard.outputs.proceed == 'true' + runs-on: ubuntu-latest + steps: + # Mint a short-lived GitHub App installation token (no PATs). The App must + # be installed on this repo with `contents: write`, and added to the + # `stable` branch-protection bypass list so it can push the release commit + # + tags. Store its credentials as RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY. + - name: Mint GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout stable + uses: actions/checkout@v7 + with: + ref: stable + # Full history + all tags for the cycle-numbering helper and tagging. + fetch-depth: 0 + # Use the App token so the commit/tag pushes below can write to the + # *protected* `stable` branch (GITHUB_TOKEN cannot). + token: ${{ steps.app-token.outputs.token }} + + - name: Set up relman + uses: ./.github/actions/setup-relman + + # The open cycle is the one being blessed: it has rc tags but no final + # `cycle-` tag yet (we create that below). + - name: Compute cycle N being released + id: cycle + uses: ./.github/actions/cycle-ids + + # Restore the authoritative consumed-UID ledger from the LAST released + # cycle tag, so the accumulation is a clean function of past releases and + # not whatever the `release-ready -> stable` merge dragged in. relman reads + # the ledger from this path to exclude already-shipped changesets by `id` + # (backport-independent dedup) during the derive below; `consume` (step 2) + # appends this cycle's ids and the release commit carries it forward on + # `stable`. See docs/release/changeset-format.md + # § "The consumed-UID ledger". + - name: Restore authoritative consumed-ledger + run: | + set -euo pipefail + ledger=".release/consumed-ledger.toml" + # Highest FINAL `cycle-` tag (no `-rc.` suffix) = the last release. + last="$(git tag --list 'cycle-*' | grep -vE 'rc\.' | sort -V | tail -1 || true)" + if [ -n "$last" ] && git cat-file -e "${last}:${ledger}" 2>/dev/null; then + mkdir -p "$(dirname "$ledger")" + git show "${last}:${ledger}" > "$ledger" + echo "Restored ${ledger} from ${last}:" + cat "$ledger" + else + # First release, or the ledger predates this feature: no authoritative + # prior. Drop any stale working-tree copy; `consume` recreates it fresh + # (relman treats an absent ledger as empty). + rm -f "$ledger" 2>/dev/null || true + echo "No prior ledger (last release: ${last:-none}); starting empty." + fi + + # 1. Derive the tag set, publish plan, and release notes from the changesets + # NOW — BEFORE bump/consume mark them. relman derives per-crate versions + # from the *unconsumed* changesets + the current Cargo.toml, so running these + # AFTER the bump (new versions) and consume (changesets marked, filtered out) + # yields an EMPTY per-crate tag set and publish plan (the cycle tag is still + # emitted, which is why a naive order produces `cycle-N` but no + # `-X.Y.Z`). Written to RUNNER_TEMP so they are never swept into the + # release commit by `git add -A`. An empty publish plan is an empty file: + # relman keeps stdout machine-readable and puts its note on stderr. + - name: Derive tag + publish plans and release notes (before consuming changesets) + run: | + set -euo pipefail + relman tags --cycle "${{ steps.cycle.outputs.open }}" > "$RUNNER_TEMP/tags.txt" + relman publish-plan > "$RUNNER_TEMP/plan.txt" + relman pr-body --cycle "${{ steps.cycle.outputs.open }}" > "$RUNNER_TEMP/notes.md" + echo "--- tag plan ---"; cat "$RUNNER_TEMP/tags.txt" + echo "--- publish plan ---"; cat "$RUNNER_TEMP/plan.txt" + if [ ! -s "$RUNNER_TEMP/plan.txt" ]; then + echo "::error::No crate publishes this cycle (no unconsumed changeset affects a governed target) — nothing to bless." + exit 1 + fi + + # 2. Apply versions + changelogs, consume changesets. All working-tree edits. + # `bump` also refreshes Cargo.lock so the `--locked` steps below pass. + # `consume` MARKS each changeset with `consumed_in = "cycle-N"` (it does not + # delete): derivation filters consumed changesets out, so the next cycle sees + # an empty set while `.changesets/` keeps the provenance ledger. The stamp is + # delivered to `dev` by the stable->dev backport, same as the bump/changelog + # edits. See docs/release/changeset-format.md § Lifecycle. + - name: Apply bump, changelog, consume changesets + run: | + set -euo pipefail + relman bump # edit manifests + root pins + Cargo.lock + relman changelog # write CHANGELOGs + # `consume` stores its --cycle verbatim as the on-disk audit stamp, so + # we pass the fully-rendered period id `cycle-N` (matching the git tag) + # for a clean cross-reference — unlike `tags`/`pr-body`, which take the + # bare ordinal N and render the `cycle-` prefix themselves. + relman changeset consume --cycle "cycle-${{ steps.cycle.outputs.open }}" --yes # mark consumed + + # 3. Commit the release edits to the working tree FIRST: `cargo publish` + # refuses a dirty package directory, and the per-crate CHANGELOG.md and + # Cargo.toml edits above live inside the packages. Nothing is pushed until + # pre-flight passes. + - name: Commit release (local) + id: commit + run: | + set -euo pipefail + if [ -z "$(git status --porcelain)" ]; then + echo "::error::No release edits produced by bump/changelog/consume — nothing to bless (were there any changesets?)." + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "chore(release): cycle-${{ steps.cycle.outputs.open }}" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + # 4. Pre-flight publishability check — validate the ENTIRE publish plan is + # actually publishable (no version-reuse, no yanked/unresolvable deps) + # BEFORE any push/tag/Release, so a real release fails fast and leaves + # nothing orphaned on stable. BLOCKING for a real release; ADVISORY under + # RELMAN_PUBLISH_DRY_RUN (a fork can't truly publish — crate names are a + # global namespace — so it warns and proceeds to exercise the rest). + - name: Pre-flight publishability check + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + DRY_RUN: ${{ vars.RELMAN_PUBLISH_DRY_RUN }} + run: | + set -euo pipefail + block() { # abort the release (real) or just warn (dry-run) + if [ "${DRY_RUN:-}" = "true" ]; then echo "::warning::pre-flight (advisory): $1"; + else echo "::error::pre-flight: $1"; exit 1; fi + } + guard_mode=blocking; [ "${DRY_RUN:-}" = "true" ] && guard_mode=advisory + cargo run -q --manifest-path tools/workbench/Cargo.toml \ + --bin check-published-versions -- --mode "$guard_mode" \ + || block "version-reuse violations — bump the offending crates before releasing." + while read -r crate version; do + [ -z "${crate:-}" ] && continue + if out="$(cargo publish -p "${crate}" --locked --dry-run --no-verify 2>&1)"; then + echo "pre-flight ok: ${crate} ${version}" + else + echo "${out}" | tail -5 + block "${crate} ${version} is not publishable (see above)." + fi + done < "$RUNNER_TEMP/plan.txt" + + # 5. Push the release commit to `stable`. The push is made by the App, so + # the `guard` job above recognises it as this pipeline's own commit and + # does not re-run the release. + - name: Push release commit + run: | + set -euo pipefail + git push origin "HEAD:refs/heads/stable" + + # 6. Tags: final `cycle-` + per-crate `-X.Y.Z`, all at the release + # commit. tags.txt was derived in step 1, before the changesets were + # consumed. All tags go in ONE push: `ci.yml` excludes these tag shapes from + # its `push.tags` trigger, and a single push keeps the release atomic on the + # remote (no half-tagged release if the runner dies mid-loop). + - name: Create and push release tags + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + refs=() + while IFS= read -r tag; do + [ -z "$tag" ] && continue + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "Tag $tag already exists; skipping." + continue + fi + git tag "$tag" "${{ steps.commit.outputs.sha }}" + refs+=("refs/tags/$tag") + done < "$RUNNER_TEMP/tags.txt" + if [ "${#refs[@]}" -gt 0 ]; then + git push origin "${refs[@]}" + fi + + # 7. A GitHub Release for the cycle — the visible UI entry. Body = the + # derived per-crate version table + changelog (rendered in step 1). Anchored + # on the `cycle-` tag; the per-crate `-X.Y.Z` tags are pushed + # above and referenced in the notes. Marked prerelease iff RC-only (never + # here — blessing is the final release). + - name: Create GitHub Release + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + tag="cycle-${{ steps.cycle.outputs.open }}" + if gh release view "$tag" >/dev/null 2>&1; then + gh release edit "$tag" --notes-file "$RUNNER_TEMP/notes.md" --title "Release $tag" + echo "Updated release $tag" + else + gh release create "$tag" --title "Release $tag" --notes-file "$RUNNER_TEMP/notes.md" --target stable + echo "Created release $tag" + fi + + # 8. Publish to crates.io in dependency order. + # IRREVERSIBLE unless RELMAN_PUBLISH_DRY_RUN=true (fork/sandbox testing), + # which runs `cargo publish --dry-run --no-verify` — packages each crate and + # exercises the orchestration WITHOUT uploading. (`--no-verify` is required + # in dry-run: a dependent's verify build would resolve its new dependency + # against crates.io, where this cycle's not-yet-uploaded versions don't + # exist. The fork validates the workflow wiring; leave the flag unset for a + # real release.) + - name: Publish to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + DRY_RUN: ${{ vars.RELMAN_PUBLISH_DRY_RUN }} + run: | + set -euo pipefail + + # Under dry-run the pre-flight step already exercised + # `cargo publish --dry-run` for the whole plan, and a fork cannot upload + # anyway — so there is nothing left to do here. + if [ "${DRY_RUN:-}" = "true" ]; then + echo "::notice::dry-run: upload skipped (publishability validated in pre-flight)." + exit 0 + fi + publish_flags="--locked" + + # Real release only. Re-run the version-reuse guard (blocking) as a last + # gate before the irreversible upload. + cargo run -q --manifest-path tools/workbench/Cargo.toml \ + --bin check-published-versions -- --mode blocking + + # plan.txt (" " lines, dependency order) was derived in + # step 1, before the changesets were consumed. + while read -r crate version; do + [ -z "${crate:-}" ] && continue + echo "::group::Publish ${crate} ${version}" + # shellcheck disable=SC2086 # publish_flags is intentionally word-split + if out="$(cargo publish -p "${crate}" ${publish_flags} 2>&1)"; then + echo "${out}" + else + echo "${out}" + # Idempotent re-run: an identical version already on crates.io is + # safe to skip; any other failure is real and aborts the release. + if echo "${out}" | grep -qiE 'already (exists|uploaded|published)|is already uploaded'; then + echo "::notice::${crate} ${version} already published; skipping." + else + echo "::error::cargo publish failed for ${crate} ${version}" + exit 1 + fi + fi + echo "::endgroup::" + # cargo (>= 1.66, sparse index) already blocks until the new version is + # visible in the index; this pause is extra margin before dependents + # publish. + sleep 15 + done < "$RUNNER_TEMP/plan.txt" + + # 9. Container images follow from the pushed tags: `release.yaml` builds + # and pushes the zainod images on the `zainod-X.Y.Z` tag push above. + # gh-pages doc publish is a cutover TODO. + - name: Post-publish artifacts + run: | + set -euo pipefail + echo "::notice::Container images are built by release.yaml on the zainod-X.Y.Z tag; gh-pages publish is a cutover TODO." diff --git a/.github/workflows/build-n-push-ci-image.yaml b/.github/workflows/build-n-push-ci-image.yaml index e29b963f..18f94fbd 100644 --- a/.github/workflows/build-n-push-ci-image.yaml +++ b/.github/workflows/build-n-push-ci-image.yaml @@ -11,7 +11,6 @@ on: branches: - '**' paths: - - '.env.testing-artifacts' - 'rust-toolchain.toml' - 'tools/workbench/**' - 'live-tests/test_environment/*' @@ -36,37 +35,17 @@ jobs: - name: Compute version-based image tag id: taggen run: | - source .env.testing-artifacts RUST_VERSION=$(cargo run -q --manifest-path tools/workbench/Cargo.toml --bin get-rust-version) - export RUST_VERSION ZCASH_VERSION ZEBRA_VERSION DEVTOOL_VERSION + export RUST_VERSION TAG=$(./tools/scripts/get-ci-image-tag.sh) echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Load version variables into job env id: versions run: | - set -a - source .env.testing-artifacts - set +a RUST_VERSION=$(cargo run -q --manifest-path tools/workbench/Cargo.toml --bin get-rust-version) - # Resolve ZEBRA_VERSION (the Docker Hub tag) to the git ref the - # zebra-builder source stage checks out; fails the job here on an - # unresolvable value instead of mid-build. - ZEBRA_GIT_REF=$(cargo run -q --manifest-path tools/workbench/Cargo.toml --bin get-zebra-git-ref -- "$ZEBRA_VERSION") echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" echo "RUST_VERSION=$RUST_VERSION" >> "$GITHUB_ENV" - echo "ZCASH_VERSION=$ZCASH_VERSION" >> "$GITHUB_ENV" - echo "ZEBRA_VERSION=$ZEBRA_VERSION" >> "$GITHUB_ENV" - echo "ZEBRA_GIT_REF=$ZEBRA_GIT_REF" >> "$GITHUB_ENV" - echo "DEVTOOL_VERSION=$DEVTOOL_VERSION" >> "$GITHUB_ENV" - - - name: Define build target - id: target - run: | - set -a - source ./tools/scripts/helpers.sh - TARGET=$(resolve_build_target ${{ env.ZCASH_VERSION }} ${{ env.ZEBRA_VERSION }}) - echo "target=$TARGET" >> "$GITHUB_OUTPUT" - name: Build and Push Docker Image uses: docker/build-push-action@v7 @@ -74,7 +53,6 @@ jobs: context: live-tests/test_environment file: live-tests/test_environment/Containerfile platforms: linux/amd64 - target: ${{ steps.target.outputs.target }} tags: | zingodevops/zaino-ci:latest zingodevops/zaino-ci:${{ steps.versions.outputs.sha_short }} @@ -82,7 +60,3 @@ jobs: push: ${{ github.event_name != 'workflow_dispatch' || inputs.push }} build-args: | RUST_VERSION=${{ env.RUST_VERSION }} - ZCASH_VERSION=${{ env.ZCASH_VERSION }} - ZEBRA_VERSION=${{ env.ZEBRA_VERSION }} - ZEBRA_GIT_REF=${{ env.ZEBRA_GIT_REF }} - DEVTOOL_VERSION=${{ env.DEVTOOL_VERSION }} diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml new file mode 100644 index 00000000..feabf44e --- /dev/null +++ b/.github/workflows/changeset-check.yml @@ -0,0 +1,72 @@ +name: Changeset check + +# dev-gate enforcement: a PR that changes a governed crate's source must carry a +# covering changeset. See docs/release/changeset-format.md. +# +# Rollout: ADVISORY by default — a missing changeset is reported as a warning and +# does NOT fail the check, so this can land before the team has adopted +# changesets. Set the repository variable RELMAN_ENFORCE_CHANGESETS=true to make +# violations fail (then add "Changeset coverage" to the branch's required +# checks). Genuinely malformed changesets always fail, regardless of the flag. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: [dev, rc, release-ready] + +jobs: + changeset-check: + name: Changeset coverage + # GitHub-hosted so it runs on pull requests. Skip drafts, and skip the + # backport sentinel's `sync/stable-to-dev` PR: it mechanically carries a + # release commit (Cargo.toml bumps, CHANGELOGs, and `.changesets/` + # consumed marks) back into `dev` and by design introduces no *new* + # changeset — same spirit as an RC-cut PR of already-reviewed work. The + # exemption needs all three signals — the branch name, a Bot author (the + # sentinel's App), and the `backport` label only a writer can apply — so a + # contributor cannot claim it by naming a fork branch. See + # docs/release/pipeline.md § "Reverse: the Backport Sentinel". + if: >- + github.event.pull_request.draft == false && + !(github.head_ref == 'sync/stable-to-dev' && + github.event.pull_request.user.type == 'Bot' && + contains(github.event.pull_request.labels.*.name, 'backport')) + runs-on: ubuntu-latest + steps: + - name: Checkout PR + uses: actions/checkout@v7 + with: + # Full history so the merge-base with the target branch is available + # for relman's `git diff base...HEAD`. + fetch-depth: 0 + + # `fetch-depth: 0` already brought every branch; this only refreshes the + # base ref in case it moved since the checkout resolved. + - name: Fetch base branch + run: git fetch --no-tags origin "${{ github.event.pull_request.base.ref }}" + + - name: Set up relman + uses: ./.github/actions/setup-relman + + - name: Run relman changeset check + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + ENFORCE: ${{ vars.RELMAN_ENFORCE_CHANGESETS }} + run: | + set -o pipefail + # relman exits 0 when compliant, 2 when one of this PR's changesets is + # malformed (unparseable or naming an unknown target), and 1 for any + # other violation. Only the last is advisory during rollout. + status=0 + relman changeset check --base "origin/${BASE_REF}" || status=$? + if [ "${status}" -eq 0 ]; then + echo "changeset check passed" + elif [ "${status}" -eq 2 ]; then + echo "::error::A changeset in this PR is malformed. Fix the file under .changesets/ (see the relman output above); this fails regardless of RELMAN_ENFORCE_CHANGESETS." + exit 1 + elif [ "${ENFORCE}" = "true" ]; then + echo "::error::Changeset coverage check failed. Add a covering changeset (\`relman changeset new\`) for the governed crates this PR touches, or \`relman changeset new --empty \"\"\` if it is release-irrelevant." + exit 1 + else + echo "::warning::Changeset coverage check failed — ADVISORY during rollout (not blocking). Run \`relman changeset check --base origin/${BASE_REF}\` locally for details. Set repo variable RELMAN_ENFORCE_CHANGESETS=true to enforce." + fi diff --git a/.github/workflows/changeset-rename.yml b/.github/workflows/changeset-rename.yml new file mode 100644 index 00000000..1cd34306 --- /dev/null +++ b/.github/workflows/changeset-rename.yml @@ -0,0 +1,127 @@ +# INERT until the repo variable RELMAN_PIPELINE_ACTIVE=true. Activated only at +# the coordinated pipeline cutover; until then this workflow does nothing. +# +# pr- rename bot: when a PR is opened/reopened against a pipeline branch, this +# renames the author's changeset file(s) to the canonical `pr-.toml` name and +# pushes the rename back onto the PR head branch, so changeset filenames are +# deterministic. relman does the working-tree rename; the workflow is only the +# imperative git shell around it. See docs/release/pipeline.md +# § Changesets and implementation.md (`relman changeset rename`). + +name: Changeset rename + +on: + pull_request: + types: [opened, reopened] + branches: [dev, rc, release-ready] + +# Least privilege: read PR metadata, write the PR head branch (the rename commit), +# and label the PR (a hotfix PR targets `rc`). +permissions: + contents: write + pull-requests: write + +jobs: + changeset-rename: + name: Name changeset for PR + # Master safety switch (see file header). Every job in every pipeline + # workflow carries this gate so these files can land dormant. The backport + # sentinel's `sync/stable-to-dev` PR is exempt on the same three signals + # changeset-check uses: its changesets are shipped provenance whose slugs + # the consumed ledger records, so renaming them would orphan the ledger. + if: >- + vars.RELMAN_PIPELINE_ACTIVE == 'true' && + !(github.head_ref == 'sync/stable-to-dev' && + github.event.pull_request.user.type == 'Bot' && + contains(github.event.pull_request.labels.*.name, 'backport')) + runs-on: ubuntu-latest + steps: + # A PR opened against `rc` is a hotfix (the normal path advances rc by a + # CI fast-forward, not a PR). Label it for scannability. Independent of the + # rename below and of the same-repo guard, so fork hotfix PRs get labelled + # too. GITHUB_TOKEN with pull-requests:write suffices. + - name: Label hotfix PRs (base rc) + if: github.event.pull_request.base.ref == 'rc' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr edit "${{ github.event.number }}" --add-label hotfix \ + || echo "::warning::'hotfix' label not applied (create it once on the repo)." + + # Fork PRs cannot be pushed to with GITHUB_TOKEN, so the rename cannot be + # committed back. Skip with a notice; the contributor (or a maintainer at + # merge) runs `relman changeset rename` by hand instead. + - name: Guard same-repo PR + id: guard + run: | + set -euo pipefail + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + echo "::notice::Changeset rename skipped for fork PR (cannot push to a fork head). Run \`relman changeset rename --pr ${{ github.event.number }}\` locally." + echo "same_repo=false" >> "$GITHUB_OUTPUT" + else + echo "same_repo=true" >> "$GITHUB_OUTPUT" + fi + + # Push the rename via a GitHub App token, NOT GITHUB_TOKEN: a GITHUB_TOKEN + # push does not trigger new workflow runs (GitHub's recursion guard), which + # would leave the dev-gate's re-run stuck `action_required` on the renamed + # changeset — blocking the PR if changeset-check is a required check. An + # App-token push DOES trigger the resulting `synchronize` re-run, and since + # this workflow fires only on opened/reopened (not synchronize), there is + # no loop. + - name: Mint GitHub App token + id: app-token + if: steps.guard.outputs.same_repo == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout PR head branch + if: steps.guard.outputs.same_repo == 'true' + uses: actions/checkout@v7 + with: + # Check out the real head branch (not the detached merge ref) so the + # rename commit can be pushed back to it. + ref: ${{ github.event.pull_request.head.ref }} + # Push with the App token (see the mint step above) so the resulting + # synchronize re-runs the dev-gate on the canonical pr- changeset. + token: ${{ steps.app-token.outputs.token }} + # Full history so the merge-base with the base branch is available: + # relman renames only the changesets this PR's diff added. + fetch-depth: 0 + + - name: Fetch base branch + if: steps.guard.outputs.same_repo == 'true' + run: git fetch --no-tags origin "${{ github.event.pull_request.base.ref }}" + + - name: Set up relman + if: steps.guard.outputs.same_repo == 'true' + uses: ./.github/actions/setup-relman + + - name: Rename changeset(s) to pr- and push if changed + if: steps.guard.outputs.same_repo == 'true' + env: + # Pass the PR head branch through the environment, not inline into the + # script — a branch name is attacker-controlled and inlining it risks + # script injection (actionlint expression check). + HEAD_REF: ${{ github.event.pull_request.head.ref }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + # relman runs from the repo root (discovers relman.toml upward) and only + # edits the working tree; committing/pushing is this workflow's job. It + # renames only the author changesets in this PR's diff against the + # base, never files inherited from it or already shipped. + relman changeset rename --pr "${{ github.event.number }}" --base "origin/${BASE_REF}" + + if [ -z "$(git status --porcelain .changesets/)" ]; then + echo "Changesets already canonically named; nothing to commit." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .changesets/ + git commit -m "chore: name changeset for PR #${{ github.event.number }}" + git push origin "HEAD:${HEAD_REF}" diff --git a/.github/workflows/ci-nightly.yaml b/.github/workflows/ci-nightly.yaml index 2ebca11c..8a2afc0e 100644 --- a/.github/workflows/ci-nightly.yaml +++ b/.github/workflows/ci-nightly.yaml @@ -17,8 +17,6 @@ env: jobs: pr-checks: uses: ./.github/workflows/ci.yml - with: - nextest-profile: ci compute-tag: uses: ./.github/workflows/compute-tag.yml @@ -207,3 +205,55 @@ jobs: uses: zingolabs/infrastructure/.github/actions/cargo-hack@20485fed7a080e381130ed8120419dc81acae641 with: hack-subcommand: build + + # This job is a REFERENCE publisher of the rc-gate's named signal. The rc-gate + # flow point (rc-gate.yml) never names a suite; it only requires a `success` + # check-run/commit-status whose context is `vars.RELMAN_RC_GATE_CHECK` (default + # `rc-gate`) on the commit it is about to advance rc to. This job posts exactly + # that status on the tested commit once every suite job above is green. + # + # CAVEAT: the suite jobs it `needs` run on self-hosted `arc-sh-runners`, of + # which there are none at present. Until runners return (or `ztest` delegates + # the suite to a cluster job that posts the same status directly), this job's + # `needs` never complete and NO signal is produced — the gate just won't advance + # without `force` (no false green). Swapping the producer is pure config: post + # the same context from wherever the suite actually runs; this job is not + # load-bearing. INERT until the pipeline is activated regardless. + # + # A GITHUB_TOKEN-posted commit status is fine here: rc-gate only READS it, so + # no App identity is needed (unlike deployment_status, which must re-trigger a + # workflow and therefore requires the App). + publish-rc-gate-signal: + name: Publish rc-gate signal + if: vars.RELMAN_PIPELINE_ACTIVE == 'true' + needs: + - pr-checks + - check + - fmt + - clippy + - doc + - whitespace + - cargo-hack-check + - cargo-hack-build + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Post the rc-gate status on the tested commit + env: + GH_TOKEN: ${{ github.token }} + GATE: ${{ vars.RELMAN_RC_GATE_CHECK || 'rc-gate' }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + # github.sha is the commit this nightly ran against — the exact commit + # the suite jobs above tested. rc-gate reads dev HEAD; when the nightly + # runs on dev (scheduled on the default branch, or dispatched with + # ref=dev) these coincide. Landing the status on any other ref simply + # means rc-gate won't find it and correctly refuses to advance. + gh api "repos/${{ github.repository }}/statuses/${SHA}" -X POST \ + -f state=success \ + -f context="${GATE}" \ + -f description="CI - Nightly (rc-gate suite) green" \ + -f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "Posted '${GATE}'=success on ${SHA:0:9}." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f64760eb..02ced976 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,14 +2,15 @@ name: CI - PR on: workflow_call: - inputs: - nextest-profile: - type: string - default: 'default' workflow_dispatch: push: tags: - "**" + # Release-pipeline tags (`cycle-`, `cycle--rc.`, `-X.Y.Z`) + # point at commits `stable`/`rc` already validated; blessing pushes up to + # one per governed crate, so they must not fan out into full CI runs. + - "!cycle-*" + - "!zaino*-[0-9]*.[0-9]*.[0-9]*" pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -68,11 +69,27 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 + - name: Cache the tool-crate builds + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + tools/workbench + tools/relman + + - name: Install cargo-nextest + uses: taiki-e/install-action@nextest + - name: Lint and test the workbench crate run: | cargo fmt --manifest-path tools/workbench/Cargo.toml -- --check cargo clippy --manifest-path tools/workbench/Cargo.toml --all-targets -- -D warnings - cargo test --manifest-path tools/workbench/Cargo.toml + cargo nextest run --manifest-path tools/workbench/Cargo.toml + + - name: Lint and test the relman crate + run: | + cargo fmt --manifest-path tools/relman/Cargo.toml --all -- --check + cargo clippy --manifest-path tools/relman/Cargo.toml --workspace --all-targets -- -D warnings + cargo nextest run --manifest-path tools/relman/Cargo.toml --workspace - name: Check toolchain pin run: cargo run -q --manifest-path tools/workbench/Cargo.toml --bin check-toolchain-pin @@ -104,13 +121,19 @@ jobs: compute-tag: uses: ./.github/workflows/compute-tag.yml - no-zcashd-build: - name: No-zcashd build (--no-default-features) - # The default-on `zcashd_support` feature is being deprecated; its OFF - # state must keep compiling or the deprecation path bit-rots. Runs in the - # CI container (toolchain + git deps + cargo cache) like the build job, so - # it is skipped on PRs until a self-hosted runner is available — same as the - # other compiling jobs. See docs/adr/0001-zcashd-support-feature-gate.md. + experimental-features-build: + name: Experimental-features build (--features experimental_features) + # The experimental/alpha feature set is off by default, so the normal build + # never compiles it and it bit-rots unwatched: two `#[cfg]` blocks in the + # finalised state's address-history writer kept referencing bindings the + # non-gated path had renamed away, and the feature had not compiled for some + # time before anyone tried it. This job is the thing that notices. + # + # Gated on the `experimental_features` umbrella rather than on a single + # feature, so anything added to that set is covered the day it lands. + # Deliberately separate from the main test archive, which stays on the + # shipped feature set — CI should not gate release testing on a + # non-release combo (see the note on the archive step below). needs: compute-tag container: image: docker.io/zingodevops/zaino-ci:${{ needs.compute-tag.outputs.image-tag }} @@ -133,8 +156,8 @@ jobs: ~/.cargo target/debug/deps - - name: Check no-zcashd build across all three workspaces - run: makers check-no-zcashd + - name: Check the experimental feature set compiles and its tests pass + run: makers check-experimental-features build-test-artifacts: name: Build test artifacts @@ -162,20 +185,26 @@ jobs: ~/.cargo target/debug/deps + - name: Check zaino-proto `heavy` survives --no-default-features + # The archive below is built --no-default-features; this guard fails the + # job if a `default-features = false` edge silently strips `heavy`. + run: makers check-zaino-proto-heavy + - name: Build and archive tests - # --no-default-features, NOT --all-features: enable only what the CI test - # machinery needs. `test_dependencies` (the launch_inner_with_listeners - # harness path) and zcash_protocol/local-consensus are pulled in by the - # test crates' own dependency declarations and survive feature - # unification, so the harness still functions. This deliberately omits - # the product features `--all-features` was speculatively enabling: - # - zcashd_support (opt-in; docs/adr/0001, 0005): zcashd is being - # deprecated; zcashd-backed tests cfg out and are not executed in CI. + # `--workspace` now resolves to the production crates only: the live-test + # suite (e2e / clientless / zaino-testutils) is a separate standalone + # workspace under live-tests/, driven by the ztest k8s harness, and is not + # a member here. So this archives and runs the packages/* unit + crate + # integration tests. + # + # --no-default-features, NOT --all-features: enable only what the tests + # need. This deliberately omits the product features `--all-features` was + # speculatively enabling: # - experimental_features / transparent_address_history_experimental: # not shipped on by default; CI should not gate on a non-release combo. + # Compiled and tested separately by `experimental-features-build`, so + # omitting it here no longer means it goes unbuilt. # - prometheus. - # This also matches what local `makers container-test` runs by default, so - # CI and local exercise the same feature set. run: cargo nextest archive --verbose --workspace --no-default-features --archive-file nextest-archive.tar.zst - name: Save cargo dependencies @@ -211,39 +240,25 @@ jobs: fail-fast: false matrix: partition: - # Regenerated with `makers update-test-targets`, then edited by hand - # for the two exclusions below. The generator cannot express them: its - # sed range stops at the first comment inside this array, so re-running - # it appends rather than replaces. Diff its output against this list - # rather than pasting over it. - # - # `clientless::chain_cache` is excluded: timeouts/hangs pending - # chain-index integration, re-enable per - # https://github.com/zingolabs/zaino/issues/1312. + # Production crates only. The live-test suite (clientless / e2e / + # zaino-testutils) is a separate standalone workspace under + # live-tests/ and runs on the ztest k8s harness, so it is not in this + # job's `--workspace` archive. # - # The e2e partitions (e2e::{compact_block_wire, devtool, - # ironwood_activation, test_vectors}) are excluded: they live in the - # unified --workspace archive and are compiled every run, but running - # the validator-heavy suite here is a CI-capacity decision tracked in - # https://github.com/zingolabs/zaino/issues/1308. - # - # `clientless::json_server` and `e2e::devtool_zcashd` are absent for a - # different reason: both are `#![cfg(feature = "zcashd_support")]` and - # the archive is built --no-default-features, so their partitions hold - # zero tests and `--no-tests fail` would fail the job. - - "clientless::compact_block_consistency" - - "clientless::fetch_service" - - "clientless::state_service" - - "clientless::test_vectors" - - "clientless::the_pub_testnet_ironwood_boundary" - - "clientless::validator_heights" + # Maintained by hand: add a line when a crate gains its first test, + # or a `crate::target` line when it gains a second test binary. - "zaino-address" + - "zaino-chain-head" + - "zaino-chain-head-service" + - "zaino-chain-store" + - "zaino-chain-store-zainodb" - "zaino-common" - "zaino-consensus" - "zaino-convert-zebra" - "zaino-mempool" - "zaino-mempool-service" - "zainod" + - "zaino-encoding" - "zaino-primitives" - "zaino-proto" - "zaino-rpc" @@ -255,7 +270,6 @@ jobs: - "zaino-source-zebra-rpc::block_parity" - "zaino-state" - "zaino-state::source_capabilities" - - "zaino-testutils" steps: - uses: actions/checkout@v7 @@ -272,11 +286,6 @@ jobs: ~/.cargo target/debug/deps - - name: Setup test binaries - run: | - # Run the entrypoint script with the actual working directory - /usr/local/bin/entrypoint.sh "$(pwd)" - - name: Restore nextest archive from S3 uses: tespkg/actions-cache/restore@v1 with: @@ -288,19 +297,8 @@ jobs: path: nextest-archive.tar.zst - name: Run Tests (Nextest) - env: - TEST_BINARIES_DIR: ${{ github.workspace }}/live-tests/test_binaries/bins run: | - # Retries are NOT set here: the `default` profile scopes retries = 2 to - # the live packages (e2e/clientless) and keeps production unit partitions - # at retries = 0. A global `--retries` flag would blanket the unit tests too. - # - # `--no-tests fail` guards against a mistyped partition (binary_id matching - # nothing). - cargo nextest run --verbose --profile ${{ inputs.nextest-profile || 'default' }} --no-tests fail --archive-file nextest-archive.tar.zst \ + # `--no-tests fail` guards against a mistyped partition (binary_id + # matching nothing). + cargo nextest run --verbose --no-tests fail --archive-file nextest-archive.tar.zst \ --workspace-remap ./ --filterset "binary_id(${{ matrix.partition }})" - - - name: Test Summary - uses: test-summary/action@v2 - with: - paths: "target/nextest/${{ inputs.nextest-profile || 'default' }}/junit.xml" diff --git a/.github/workflows/compute-tag.yml b/.github/workflows/compute-tag.yml index c7d279f1..6399d77e 100644 --- a/.github/workflows/compute-tag.yml +++ b/.github/workflows/compute-tag.yml @@ -20,9 +20,8 @@ jobs: - name: Compute version-based image tag id: taggen run: | - source .env.testing-artifacts RUST_VERSION=$(cargo run -q --manifest-path tools/workbench/Cargo.toml --bin get-rust-version) - export RUST_VERSION ZCASH_VERSION ZEBRA_VERSION DEVTOOL_VERSION DOCKER_DIR_HASH + export RUST_VERSION TAG=$(./tools/scripts/get-ci-image-tag.sh) echo "TAG=$TAG" echo "tag=$TAG" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/deployment-advance.yml b/.github/workflows/deployment-advance.yml new file mode 100644 index 00000000..9f809a4a --- /dev/null +++ b/.github/workflows/deployment-advance.yml @@ -0,0 +1,98 @@ +# ============================================================================ +# DEPLOYMENT ADVANCE — the reaction half of the deployment-gate bridge. +# +# INERT until the repo variable RELMAN_PIPELINE_ACTIVE=true. +# +# The cluster (Argo) runs the live-chain deployment validation for an RC commit +# (warm-tip fixtures in minutes–hours, or a fresh full-index sync over days) and, on +# completion, posts a `deployment_status` back to that commit's GitHub Deployment +# (environment `deployment`, created by rc-gate). This workflow reacts: on a +# SUCCESS it fast-forwards `release-ready` to the deployed commit — the automated +# equivalent of the manual `deploy-pass`. A failed soak advances nothing; `rc` +# keeps moving and a later RC gets its own deployment run (coalesce-to-latest). +# +# `deployment_status` events run the workflow from the DEFAULT branch, so this +# file must be on the default branch at cutover. `tools/scripts/mark-deployment.sh` +# posts the same signal to exercise this reaction in seconds instead of days. +# +# Any account with `deployments: write` can post a `success`, so set the repo +# variable RELMAN_DEPLOYMENT_REPORTER to the login of the account the cluster +# posts as; once set, a status from any other login is refused. Leave it unset +# while the bridge is exercised by hand with mark-deployment.sh. +# See docs/release/implementation.md § "The deployment-gate bridge". +# ============================================================================ + +name: Deployment advance + +on: + deployment_status: + +# Fast-forwarding the protected `release-ready` branch is done with the App +# token minted below; the workflow-level permission is the least that is needed. +permissions: + contents: read + +jobs: + advance: + name: Advance release-ready on a passed deployment + # Gate + react only to a SUCCESSFUL soak in the `deployment` environment. + if: >- + vars.RELMAN_PIPELINE_ACTIVE == 'true' && + github.event.deployment_status.state == 'success' && + github.event.deployment.environment == 'deployment' + runs-on: ubuntu-latest + steps: + - name: Require the status from the configured reporter + if: vars.RELMAN_DEPLOYMENT_REPORTER != '' + env: + REPORTER: ${{ vars.RELMAN_DEPLOYMENT_REPORTER }} + CREATOR: ${{ github.event.deployment_status.creator.login }} + run: | + set -euo pipefail + if [ "${CREATOR}" != "${REPORTER}" ]; then + echo "::error::deployment_status posted by '${CREATOR}', not the configured reporter '${REPORTER}'; refusing to advance release-ready." + exit 1 + fi + echo "deployment_status posted by the configured reporter '${REPORTER}'." + + # App token: the ff push to the protected `release-ready` branch cannot be + # done with GITHUB_TOKEN, and an App-token push (unlike GITHUB_TOKEN) also + # re-triggers `release-pr-body` on `release-ready` to refresh the release PR. + - name: Mint GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Fast-forward release-ready to the deployed commit + env: + DEPLOYED_SHA: ${{ github.event.deployment.sha }} + run: | + set -euo pipefail + git fetch --no-tags --quiet origin release-ready rc || true + + # Only ADVANCE, never rewind: the deployed commit must be a descendant + # of the current release-ready tip. An out-of-order / older deployment + # success is ignored — a newer RC that also passes moves it forward. + rr="$(git rev-parse origin/release-ready 2>/dev/null || echo '')" + if [ -n "$rr" ] && ! git merge-base --is-ancestor "$rr" "$DEPLOYED_SHA"; then + echo "::notice::deployed ${DEPLOYED_SHA} is not ahead of release-ready (${rr}); nothing to advance." + exit 0 + fi + + # Only graduate a commit that actually came through the candidate line: + # the deployed sha must be on `rc`'s history, never an arbitrary sha. + if ! git merge-base --is-ancestor "$DEPLOYED_SHA" origin/rc; then + echo "::warning::deployed ${DEPLOYED_SHA} is not on rc's history; refusing to graduate it." + exit 0 + fi + + git push origin "${DEPLOYED_SHA}:refs/heads/release-ready" + echo "release-ready advanced to ${DEPLOYED_SHA} (deployment passed) — release-pr-body will refresh the release PR." diff --git a/.github/workflows/deployment-signoff.yml b/.github/workflows/deployment-signoff.yml new file mode 100644 index 00000000..43fb73f9 --- /dev/null +++ b/.github/workflows/deployment-signoff.yml @@ -0,0 +1,106 @@ +# INERT until the repo variable RELMAN_PIPELINE_ACTIVE=true. +# +# Manual deployment-gate sign-off — the human-driven path for the same signal +# the cluster deployment gate posts automatically. +# +# The deployment gate (rc -> release-ready) is normally driven by the cluster: +# the deployment-gate poller submits the live-chain validation WorkflowTemplate, +# which posts a `deployment_status` back to the RC's Deployment (environment +# `deployment`); deployment-advance.yml reacts to `success` by fast-forwarding +# release-ready. This workflow lets a human post that same verdict by hand — for +# the interim before the cluster validation is wired, or to override a soak. +# +# It posts as the release GitHub App, NOT GITHUB_TOKEN: a `deployment_status` +# posted with GITHUB_TOKEN does not re-trigger deployment-advance. This is the +# same reason the cluster poller posts as the App. +# +# PRECONDITION for manual mode: the cluster deployment-gate poller must not be +# auto-passing, or it will claim the Deployment (post a status first) before a +# human can. Suspend the `deployment-gate-poller` CronWorkflow when gating by +# hand. A Deployment that already carries any status is considered claimed and is +# skipped by the blank-id resolver below. + +name: Deployment gate sign-off (manual) + +on: + workflow_dispatch: + inputs: + state: + description: "Deployment-gate verdict for the RC." + type: choice + options: + - success + - failure + default: success + deployment_id: + description: "Target Deployment id. Blank = the newest unclaimed Deployment in the `deployment` environment." + type: string + required: false + +# Everything here authenticates as the App; GITHUB_TOKEN is used only for the +# checkout-less API reads, which need no elevated scope. +permissions: + contents: read + +jobs: + signoff: + name: Post deployment_status as the release App + if: vars.RELMAN_PIPELINE_ACTIVE == 'true' + runs-on: ubuntu-latest + steps: + # Same App/secrets as rc-gate.yml and the cluster gate. The App must be + # installed with `deployments: write`; posting as it (vs GITHUB_TOKEN) is + # what lets deployment-advance react to the resulting status. + - name: Mint GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Resolve target deployment + id: dep + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + DEP_ID: ${{ inputs.deployment_id }} + run: | + set -euo pipefail + if [ -n "${DEP_ID}" ]; then + id="${DEP_ID}" + else + # Newest Deployment in the `deployment` environment with no status yet + # (unclaimed by the cluster gate). Same dedup rule as the poller: a + # Deployment's own status is its claim state. + id="" + for d in $(gh api \ + "repos/${{ github.repository }}/deployments?environment=deployment&per_page=20" \ + --jq '.[].id'); do + n="$(gh api "repos/${{ github.repository }}/deployments/${d}/statuses?per_page=1" \ + --jq 'length')" + if [ "$n" = "0" ]; then id="$d"; break; fi + done + if [ -z "$id" ]; then + echo "::error::No unclaimed Deployment in the 'deployment' environment. Pass deployment_id explicitly, or an earlier gate already recorded a verdict." + exit 1 + fi + fi + echo "id=$id" >> "$GITHUB_OUTPUT" + echo "Targeting deployment ${id}." + + - name: Post deployment_status + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + STATE: ${{ inputs.state }} + ID: ${{ steps.dep.outputs.id }} + run: | + set -euo pipefail + gh api "repos/${{ github.repository }}/deployments/${ID}/statuses" -X POST \ + -f state="${STATE}" \ + -f environment=deployment \ + -f description="deployment gate (manual sign-off): ${STATE}" + echo "Posted deployment_status=${STATE} for deployment ${ID} (manual sign-off)." + if [ "${STATE}" = "success" ]; then + echo "deployment-advance will fast-forward release-ready to this RC." + else + echo "Verdict recorded; release-ready is not advanced." + fi diff --git a/.github/workflows/final-tag-on-stable.yml b/.github/workflows/final-tag-on-stable.yml index d21c6d3b..5b8c64e3 100644 --- a/.github/workflows/final-tag-on-stable.yml +++ b/.github/workflows/final-tag-on-stable.yml @@ -1,3 +1,7 @@ +# LEGACY tag producer for the pre-cutover release protocol (rc/ +# branches). It stays live until the repo variable RELMAN_PIPELINE_ACTIVE is +# set to true, at which point the release pipeline (rc-gate / blessing) owns +# every tag and this workflow goes dormant. Delete it after the cutover. name: Final Tag on Stable on: @@ -8,7 +12,7 @@ on: jobs: tag-stable: - if: github.event.pull_request.merged == true + if: github.event.pull_request.merged == true && vars.RELMAN_PIPELINE_ACTIVE != 'true' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/hotfix-notice.yml b/.github/workflows/hotfix-notice.yml new file mode 100644 index 00000000..316c4c1f --- /dev/null +++ b/.github/workflows/hotfix-notice.yml @@ -0,0 +1,63 @@ +# ============================================================================ +# HOTFIX NOTICE — note an un-backported hotfix on the standing release PR. +# +# INERT until RELMAN_PIPELINE_ACTIVE=true. +# +# `rc` is normally advanced by a fast-forward to `dev` (rc-gate), which leaves +# `rc` an ancestor of `dev`. A HOTFIX lands directly on `rc`, so `rc` gains a +# commit `dev` lacks and is no longer an ancestor of `dev`. This workflow detects +# that shape on every push to `rc` and, when a release PR is open, appends a note +# to its timeline (the body is the live snapshot; comments are the event log). +# +# It never mutates anything but a PR comment; GITHUB_TOKEN with +# pull-requests:write is enough (a comment triggers nothing, so no App token). +# ============================================================================ + +name: Hotfix notice + +on: + push: + branches: [rc] + +permissions: + contents: read + pull-requests: write + +jobs: + notice: + name: Note hotfix on the release PR + if: vars.RELMAN_PIPELINE_ACTIVE == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Comment if rc carries un-backported (hotfix) commits + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git fetch --no-tags --quiet origin dev rc + # A dev fast-forward leaves rc an ancestor of dev; a hotfix does not. + if git merge-base --is-ancestor origin/rc origin/dev; then + echo "rc is an ancestor of dev (normal advance) — nothing to note." + exit 0 + fi + pr="$(gh pr list --base stable --head release-ready --state open \ + --json number --jq '.[0].number // empty')" + if [ -z "$pr" ]; then + echo "::notice::rc carries hotfix commits but no release PR is open to note it on." + exit 0 + fi + full="$(git rev-parse origin/rc)" + sha="${full:0:9}" + # The newest NON-merge commit rc has that dev lacks — the hotfix itself, + # not the merge commit that brought it in. Fall back to rc's tip subject. + subj="$(git log origin/dev..origin/rc --no-merges --format='%s' | head -1)" + [ -n "$subj" ] || subj="$(git log -1 --format='%s' origin/rc)" + repo="${{ github.server_url }}/${{ github.repository }}" + gh pr comment "$pr" --body \ + "🚑 Hotfix landed on \`rc\` at [\`${sha}\`](${repo}/commit/${full}): ${subj}. Backport to \`dev\` is required before rc can advance (rc-gate will refuse until then)." + echo "Noted hotfix on release PR #${pr}" diff --git a/.github/workflows/live-tests.yaml b/.github/workflows/live-tests.yaml new file mode 100644 index 00000000..00c38f7d --- /dev/null +++ b/.github/workflows/live-tests.yaml @@ -0,0 +1,129 @@ +name: Live tests + +on: + # Fork PRs cannot mint the OIDC token (`pull_request` from a fork is capped read-only), so a + # maintainer runs them from here: base-repo context, full permissions, behind the environment gate. + workflow_dispatch: + inputs: + pr: + description: PR number to run the suite against (blank = this branch) + required: false + type: string + pull_request: + +# Per-PR — a push supersedes its own run, never another PR's +# - ztest queues on contention (`qos/scheduler.rs::decide`), so the cluster arbitrates, not this +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # Cluster-free: compiles the live suite without standing up a cluster. + compile: + name: Compile check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.pr && format('refs/pull/{0}/merge', inputs.pr) || '' }} + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + live-tests/target + key: live-tests-compile-${{ hashFiles('live-tests/Cargo.lock') }} + restore-keys: live-tests-compile- + + - name: Format + run: cargo fmt --manifest-path live-tests/Cargo.toml --all --check + + - name: Clippy (all features) + run: > + cargo clippy --manifest-path live-tests/Cargo.toml + --all-targets --all-features --locked -- -D warnings + + run: + name: Run on cluster + needs: compile + # Fork PRs skip rather than fail: read-only token = no OIDC = no tailnet. Run them by hand + # via workflow_dispatch, which GitHub already restricts to write access. + if: > + github.event_name == 'workflow_dispatch' + || github.event.pull_request.head.repo.full_name == github.repository + # Unconditional, and load-bearing beyond any protection rules: the tailnet trust credential + # matches subject `repo:zingolabs/zaino:environment:ztest-cluster`, and a job without an + # `environment:` mints `…:pull_request` instead → 403 at the join. + environment: ztest-cluster + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + id-token: write # mints the GitHub OIDC token Tailscale federates on + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.pr && format('refs/pull/{0}/merge', inputs.pr) || '' }} + + # CLI and library must be the same version: ztest verifies a hash of the + # ClusterRole it renders against the applied one at run start. So the version is derived, + # never floated to latest. + - name: Resolve ztest version from the lockfile + id: ztest + run: | + v=$(awk '/^name = "ztest"$/ { getline; print $3 }' live-tests/Cargo.lock | tr -d '"') + [ -n "$v" ] || { echo "no ztest version in live-tests/Cargo.lock"; exit 1; } + echo "version=$v" >> "$GITHUB_OUTPUT" + + # Caches the built binary itself. `cargo install` keys off `~/.cargo/.crates.toml`, which a + # `~/.cargo/bin` cache does not carry — so a plain cache restores the binary and rebuilds anyway. + - name: Install ztest CLI + uses: baptiste0928/cargo-install@v3 + with: + crate: ztest_cli + version: ${{ steps.ztest.outputs.version }} + locked: true + + - uses: azure/setup-kubectl@v4 + + # Workload identity federation (GitHub OIDC) — no stored secret. Client ID and + # audience are non-secret repo *variables*. tag:ztest-runner reaches + # tag:k8s-operator:443 only — no node, no SSH. + - name: Join tailnet (ephemeral tag:ztest-runner, OIDC — no secret) + uses: tailscale/github-action@v4 + with: + oauth-client-id: ${{ vars.TS_OIDC_CLIENT_ID }} + audience: ${{ vars.TS_OIDC_AUDIENCE }} + tags: tag:ztest-runner + + # Authenticates as the tailnet identity; no kube token exists to leak. + # The tailnet grant impersonates tag:ztest-runner into the k8s group `ztest-ci`. + - name: Configure kubeconfig via the operator proxy + run: tailscale configure kubeconfig tailscale-operator + + - name: Register the cluster + run: | + ztest cluster add zingo-infra \ + --kube-context "$(kubectl config current-context)" \ + --extra-config '${{ vars.ZTEST_CLUSTER_CONFIG_URL }}' \ + --set-default --yes + + # Probes every (resource, verb) pair the run role grants, so a permission gap + # is named here rather than 403-ing twenty minutes into a run. + - name: Check the cluster + run: ztest cluster check + + - name: Run the live suite + working-directory: live-tests + run: ztest run + + # - no janitor on zingo-infra (`janitor/ttl` is advisory) → a cancelled run leaks without this + # - targeted by run id: every CI run shares `ztest.io/user=runner`, untargeted reaps peers + - name: Reclaim this run's cluster resources + if: always() + continue-on-error: true + run: ztest cleanup --force '${{ github.run_id }}' diff --git a/.github/workflows/publish-dry-run.yml b/.github/workflows/publish-dry-run.yml index f2fb2e94..2800a9db 100644 --- a/.github/workflows/publish-dry-run.yml +++ b/.github/workflows/publish-dry-run.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: branches: - - 'rc/**' + - rc - stable pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -13,11 +13,12 @@ jobs: publish-dry-run: name: cargo publish --dry-run runs-on: ubuntu-latest - # Blocking context: direct pushes to rc/** or stable — the branch is in - # release-prep state and must publish cleanly. Advisory context: everything - # else, including PRs that *target* rc/** or stable. Those PRs carry - # unbumped dev code; version bumps happen on the RC branch after merge, so - # a publish failure at PR time is expected, not actionable. + # Blocking context: pushes to stable — the release commit is on it and must + # publish cleanly. Advisory context: everything else, including pushes to + # `rc` and PRs that *target* rc or stable. Every pipeline branch is + # version-agnostic and `rc` carries unbumped code by design (versions are + # bumped at blessing), so a publish failure there is expected, not + # actionable; blessing's own pre-flight is the blocking check at release. steps: - name: Checkout repository uses: actions/checkout@v7 @@ -31,11 +32,11 @@ jobs: if [[ "${{ github.event_name }}" == "push" ]]; then target="${{ github.ref_name }}" else - # PRs (including those targeting rc/** or stable) are always + # PRs (including those targeting rc or stable) are always # advisory — they carry pre-bump code by design. target="" fi - if [[ "$target" == rc/* || "$target" == stable ]]; then + if [[ "$target" == stable ]]; then echo "mode=blocking" >> "$GITHUB_OUTPUT" else echo "mode=advisory" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/rc-gate.yml b/.github/workflows/rc-gate.yml new file mode 100644 index 00000000..7ce3608f --- /dev/null +++ b/.github/workflows/rc-gate.yml @@ -0,0 +1,226 @@ +# INERT until the repo variable RELMAN_PIPELINE_ACTIVE=true. Activated only at +# the coordinated pipeline cutover; until then this workflow does nothing. +# +# rc-gate: nightly RC advancement + prerelease tag. Fast-forwards `rc` to `dev` +# HEAD and cuts a `cycle--rc.` prerelease tag at the new rc HEAD, which is +# what the deployment gate (release-gate) is pinned to. This is CI glue: relman prints the +# tag name, the workflow applies it. See +# docs/release/pipeline.md § "The rc-gate" and "Promotion Flow". +# +# NOTE ON THE NIGHTLY-GREEN PRECONDITION: advancement requires the `rc-gate` +# named signal (a check-run/commit-status, context `vars.RELMAN_RC_GATE_CHECK`) +# to be green on dev HEAD. `CI - Nightly` publishes it on a fully-green run; this +# workflow only reads it (a `force` dispatch bypasses). The gate does not run the +# suite itself — that would duplicate the runner. +# +# IDEMPOTENT: a night on which `dev` did not move cuts nothing. The prerelease +# tag is applied only when rc HEAD differs from the commit the last rc tag of +# the open cycle points at, so a quiet weekend never restarts the deployment +# soak with a duplicate tag at the same commit. + +name: RC gate + +on: + schedule: + - cron: '30 4 * * *' # nightly at 04:30 UTC + workflow_dispatch: + inputs: + force: + description: "Advance rc even though the nightly rc-gate green condition is not wired yet (draft escape hatch)." + type: boolean + default: false + +# rc advancement pushes the protected `rc` branch and a prerelease tag; it also +# notes the cut on the standing release PR (comment) and re-renders its body, +# and dispatches the cut RC to the deployment gate via a GitHub Deployment. +permissions: + contents: write + pull-requests: write + actions: write + deployments: write + +jobs: + rc-gate: + name: Advance rc and cut prerelease tag + if: vars.RELMAN_PIPELINE_ACTIVE == 'true' + runs-on: ubuntu-latest + steps: + # Mint a short-lived GitHub App installation token (no PATs). The App must + # be installed on this repo with `contents: write`, and added to the `rc` + # branch-protection bypass list so it can fast-forward protected `rc`. + # Store its credentials as the RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY + # secrets. + - name: Mint GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout dev + uses: actions/checkout@v7 + with: + ref: dev + # Full history, every branch, and all tags: needed for the + # fast-forward checks and the cycle-numbering helper. No separate + # fetch is needed for `rc`. + fetch-depth: 0 + # Use the App token so the push step below can advance the *protected* + # `rc` branch (GITHUB_TOKEN cannot push a protected branch). + token: ${{ steps.app-token.outputs.token }} + + # Gate 2 (the rc-gate suite) must be green for the EXACT dev commit rc is + # about to advance to. The gate is DECOUPLED from what runs the suite: it + # requires a NAMED SIGNAL — a check-run or commit status whose context is + # `vars.RELMAN_RC_GATE_CHECK` (default `rc-gate`) — to be `success` on dev + # HEAD. `CI - Nightly` (ci-nightly.yaml) is the current publisher — its + # `publish-rc-gate-signal` job posts this status on a fully-green run — but + # the gate never names it: whatever publishes the signal (a nextest e2e + # workflow, an external runner, a manual sign-off) is the swappable + # membership. A `force` dispatch bypasses (recovery, or before the suite + # has run for a fresh commit). + - name: Require the rc-gate suite green on dev HEAD + if: ${{ ! inputs.force }} + env: + GH_TOKEN: ${{ github.token }} + GATE: ${{ vars.RELMAN_RC_GATE_CHECK || 'rc-gate' }} + run: | + set -euo pipefail + head="$(git rev-parse HEAD)" # the checked-out dev tip + # Accept either a check-run (Actions/App) or a commit status (external) + # with this context = success — suite-type-agnostic. + cr="$(gh api "repos/${{ github.repository }}/commits/${head}/check-runs" \ + --jq '[.check_runs[] | select(.name == env.GATE and .conclusion == "success")] | length')" + st="$(gh api "repos/${{ github.repository }}/commits/${head}/status" \ + --jq '[.statuses[] | select(.context == env.GATE and .state == "success")] | length')" + if [ "$(( ${cr:-0} + ${st:-0} ))" -ge 1 ]; then + echo "rc-gate suite signal '${GATE}' is green for dev HEAD ${head:0:9}; advancing rc." + else + echo "::error::rc-gate suite signal '${GATE}' is not green for dev HEAD ${head:0:9}. Whatever runs the rc-gate suite must publish a '${GATE}' check-run/status; refusing to advance (dispatch force=true to override)." + exit 1 + fi + + - name: Fast-forward rc to dev HEAD + id: ff + run: | + set -euo pipefail + dev_head="$(git rev-parse origin/dev)" + + # rc may not exist yet on the very first run; treat that as "create rc at dev". + if git rev-parse -q --verify origin/rc >/dev/null; then + rc_head="$(git rev-parse origin/rc)" + else + rc_head="" + fi + + if [ "$rc_head" = "$dev_head" ]; then + echo "rc already at dev HEAD ($dev_head); nothing to advance." + elif [ -n "$rc_head" ] && ! git merge-base --is-ancestor "$rc_head" "$dev_head"; then + # rc has commit(s) dev lacks (e.g. an un-backported hotfix). A pure + # fast-forward is impossible; NEVER force. A human must backport the + # rc-only commits to dev first (see the hotfix/backport protocol). + echo "::error::rc ($rc_head) is not an ancestor of dev HEAD ($dev_head); refusing non-fast-forward advance. Backport rc-only commits to dev first (hotfix protocol)." + exit 1 + else + echo "Fast-forwarding rc -> $dev_head" + git push origin "${dev_head}:refs/heads/rc" + fi + + # The prerelease tag is applied at rc HEAD, which is now dev_head. + echo "rc_sha=$dev_head" >> "$GITHUB_OUTPUT" + + - name: Compute cycle N and next rc M + id: ids + uses: ./.github/actions/cycle-ids + + # Cut a prerelease tag only when rc HEAD moved since the open cycle's last + # rc tag: an idle night (or a re-run) finds the last tag already at this + # commit and cuts nothing, so no duplicate tag and no restarted soak. + - name: Decide whether rc HEAD needs a new prerelease tag + id: need + env: + LAST_TAG: ${{ steps.ids.outputs.last_rc_tag }} + RC_SHA: ${{ steps.ff.outputs.rc_sha }} + run: | + set -euo pipefail + if [ -n "${LAST_TAG}" ] && [ "$(git rev-parse "refs/tags/${LAST_TAG}^{commit}")" = "${RC_SHA}" ]; then + echo "::notice::${LAST_TAG} already points at rc HEAD ${RC_SHA:0:9}; nothing new to cut." + echo "cut=false" >> "$GITHUB_OUTPUT" + else + echo "cut=true" >> "$GITHUB_OUTPUT" + fi + + - name: Set up relman + if: steps.need.outputs.cut == 'true' + uses: ./.github/actions/setup-relman + + - name: Create and push prerelease tag + id: tag + if: steps.need.outputs.cut == 'true' + run: | + set -euo pipefail + # relman prints the single prerelease tag name for cycle N, rc M. + tag="$(relman tags --cycle "${{ steps.ids.outputs.open }}" --rc "${{ steps.ids.outputs.next_rc }}" \ + | tr -d '[:space:]')" + echo "Prerelease tag: $tag" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag "$tag" "${{ steps.ff.outputs.rc_sha }}" + git push origin "refs/tags/$tag" + + # Record the cut on the standing release PR: an append-only comment (the + # event timeline) plus a re-render of the body (the live snapshot). The + # release PR only exists once `release-ready` is ahead of `stable`, so an + # early rc (before the first deployment-pass) has nowhere to post — the tag + # is its record until the PR opens. GITHUB_TOKEN suffices to comment and to + # dispatch the renderer. + - name: Note the RC cut on the release PR + if: steps.need.outputs.cut == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + pr="$(gh pr list --base stable --head release-ready --state open \ + --json number --jq '.[0].number // empty')" + if [ -z "$pr" ]; then + echo "::notice::No open release PR yet; the ${{ steps.tag.outputs.tag }} tag is its own record." + exit 0 + fi + repo="${{ github.server_url }}/${{ github.repository }}" + tag="${{ steps.tag.outputs.tag }}" + sha="${{ steps.ff.outputs.rc_sha }}" + # Markdown links (backtick-wrapped SHAs/tags don't autolink on their own). + gh pr comment "$pr" --body \ + "🔖 Cut [\`${tag}\`](${repo}/tree/${tag}) at [\`${sha:0:9}\`](${repo}/commit/${sha}) — deployment: pending." + # Re-render the body snapshot (RC list / watermarks) via the sole + # renderer, keeping the description current without duplicating it here. + gh workflow run release-pr-body.yml --ref release-ready || \ + echo "::warning::could not dispatch release-pr-body to refresh the body." + + # Dispatch the freshly-cut RC to the deployment gate: create a GitHub + # Deployment for its commit in the `deployment` environment. On the cluster, + # Argo Events catches this and runs the live-chain deployment validation; on + # completion it posts a `deployment_status` back, which the + # `deployment-advance` workflow reacts to by advancing `release-ready`. + # `required_contexts:[]` so the Deployment is not gated on status checks — + # the soak itself IS the gate. See implementation.md § "The deployment-gate + # bridge". (`tools/scripts/mark-deployment.sh` injects the same signal for + # testing without waiting days.) + - name: Dispatch the RC to the deployment gate + if: steps.need.outputs.cut == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if jq -n --arg ref "${{ steps.ff.outputs.rc_sha }}" --arg tag "${{ steps.tag.outputs.tag }}" \ + '{ref:$ref, environment:"deployment", auto_merge:false, + required_contexts:[], production_environment:false, + description:("deployment-gate soak for "+$tag)}' \ + | gh api "repos/${{ github.repository }}/deployments" -X POST --input - \ + --jq '"dispatched deployment "+(.id|tostring)+" for "+.sha[0:9]'; then + : + else + echo "::warning::could not create the GitHub Deployment; the soak was not dispatched for ${{ steps.tag.outputs.tag }}." + fi diff --git a/.github/workflows/release-pr-body.yml b/.github/workflows/release-pr-body.yml new file mode 100644 index 00000000..fc3596b2 --- /dev/null +++ b/.github/workflows/release-pr-body.yml @@ -0,0 +1,168 @@ +# INERT until the repo variable RELMAN_PIPELINE_ACTIVE=true. Activated only at +# the coordinated pipeline cutover; until then this workflow does nothing. +# +# Keep the standing Release PR (`release-ready` -> `stable`) current. On every +# push to `release-ready` (deployment-pass advance), re-render the PR body from the +# derived version table + changelog and update the open PR — creating it if none +# exists. See docs/release/pipeline.md § "Blessing" and +# implementation.md (`relman pr-body`). + +name: Release PR body + +on: + push: + branches: [release-ready] + workflow_dispatch: + +# Least privilege: read the tree, write the PR (create/edit). +permissions: + contents: read + pull-requests: write + +jobs: + release-pr-body: + name: Keep release PR current + if: vars.RELMAN_PIPELINE_ACTIVE == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + # Full history + tags so the cycle-numbering helper can read them. + fetch-depth: 0 + + # Create/edit the PR with a GitHub App token: GITHUB_TOKEN cannot create + # pull requests unless the repo enables "Allow GitHub Actions to create and + # approve pull requests" (off by default). An App with pull_requests:write + # is not subject to that restriction, so this is setting-independent. + - name: Mint GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Compute open cycle N + id: cycle + uses: ./.github/actions/cycle-ids + + - name: Gather cycle status + id: status + run: | + set -euo pipefail + # relman renders; this step gathers the live git facts it renders + # from, keeping relman a pure function of a TOML file. See + # relman-core `CycleStatus` and docs/release/pipeline.md + # § "Blessing". + status="$RUNNER_TEMP/status.toml" + N="${{ steps.cycle.outputs.open }}" + + # Fetch the four gate branches so their tips are readable; each may be + # missing in an early cycle, so never fail on a branch that is absent. + for b in dev rc release-ready stable; do + git fetch --no-tags --quiet origin "$b" || true + done + + # Top-level scalars must precede the [[rc]] array-of-tables (a bare key + # after an array-of-tables header binds to that table in TOML), so + # `released_cycle` is written first, then [watermarks], then the RCs. + : > "$status" + + # released_cycle: the highest final `cycle-` tag (no -rc suffix) + # reachable on stable, if any. + if git rev-parse -q --verify origin/stable >/dev/null 2>&1; then + released="$(git tag --merged origin/stable --list 'cycle-*' \ + | sed -n 's/^cycle-\([0-9][0-9]*\)$/\1/p' \ + | sort -n | tail -1)" + if [ -n "$released" ]; then + printf 'released_cycle = "cycle-%s"\n\n' "$released" >> "$status" + fi + fi + + # [watermarks]: the short sha at each gate branch tip; a missing + # branch simply contributes no row. + echo "[watermarks]" >> "$status" + wm() { + local sha + sha="$(git rev-parse --short "$2" 2>/dev/null || true)" + [ -n "$sha" ] && printf '%s = "%s"\n' "$1" "$sha" >> "$status" || true + } + wm dev origin/dev + wm rc origin/rc + wm release_ready origin/release-ready + wm stable origin/stable + + # [[rc]]: every cycle--rc. prerelease tag for the open cycle, + # sorted by M, each with the short sha it points at. + # TODO: populate deployment from the GitHub Deployments deployment-gate bridge + # (the Argo side is out of scope here); until then, `pending`. + rc_nums="$(git tag --list "cycle-${N}-rc.*" \ + | sed -n "s/^cycle-${N}-rc\.\([0-9][0-9]*\)$/\1/p" \ + | sort -n)" + for m in $rc_nums; do + tag="cycle-${N}-rc.${m}" + sha="$(git rev-parse --short "refs/tags/$tag" 2>/dev/null || true)" + [ -n "$sha" ] || continue + { + printf '\n[[rc]]\n' + printf 'tag = "%s"\n' "$tag" + printf 'sha = "%s"\n' "$sha" + printf 'deployment = "pending"\n' + } >> "$status" + done + + echo "Gathered $status:" + cat "$status" + echo "path=$status" >> "$GITHUB_OUTPUT" + + # Overwrite the working-tree consumed-UID ledger with `stable`'s authoritative + # copy, so the derived version table excludes everything already shipped + # without waiting for the `stable -> dev` backport to land. relman reads the + # ledger from this path. `stable` was fetched in "Gather cycle status" above. + # See docs/release/changeset-format.md § "The consumed-UID ledger". + - name: Refresh consumed-ledger from stable + run: | + set -euo pipefail + ledger=".release/consumed-ledger.toml" + if git cat-file -e "origin/stable:${ledger}" 2>/dev/null; then + mkdir -p "$(dirname "$ledger")" + git show "origin/stable:${ledger}" > "$ledger" + echo "Refreshed ${ledger} from origin/stable." + else + rm -f "$ledger" 2>/dev/null || true + echo "stable has no ledger yet; treating as empty." + fi + + - name: Set up relman + uses: ./.github/actions/setup-relman + + - name: Render release PR body + run: | + set -euo pipefail + # relman runs from the repo root and writes markdown to stdout. + relman pr-body --cycle "${{ steps.cycle.outputs.open }}" \ + --status "${{ steps.status.outputs.path }}" > body.md + + - name: Create or update the release PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + num="$(gh pr list --base stable --head release-ready --state open \ + --json number --jq '.[0].number // empty')" + if [ -n "$num" ]; then + gh pr edit "$num" --body-file body.md + echo "Updated release PR #$num" + elif [ "$(git rev-list --count origin/stable..origin/release-ready)" -eq 0 ]; then + # release-ready has not advanced past stable yet, so there is no diff + # to open a PR from (a re-render triggered by an early rc cut lands + # here). Nothing to do — the cut is recorded by its tag. + echo "::notice::release-ready is not ahead of stable; no release PR to open yet." + else + new="$(gh pr create --base stable --head release-ready \ + --title "Release cycle-${{ steps.cycle.outputs.open }}" \ + --body-file body.md | sed 's#.*/##')" + gh pr edit "$new" --add-label "automated,release" \ + || echo "::warning::labels 'automated'/'release' not applied (create them once on the repo)." + echo "Opened release PR #$new (release-ready -> stable)" + fi diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7b1f5d74..3c7471dc 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,3 +1,11 @@ +# Builds and pushes the Docker images. It fires on the legacy version tags +# (`X.Y.Z`, `X.Y.Z-rc.N`, cut by auto-tag-rc / final-tag-on-stable until the +# pipeline cutover) and on the per-crate `zainod-X.Y.Z` provenance tag the +# release pipeline's blessing pushes, from which the image version is taken. +# The pipeline's `cycle-` and other `-X.Y.Z` tags carry no zainod +# image and are ignored here. Blessing owns the GitHub Release for pipeline +# tags, so the `create-release` job below runs only for the legacy tags. +# TODO(cutover): gh-pages doc publish. name: Zaino Release on: @@ -5,6 +13,7 @@ on: tags: - '[0-9]+.[0-9]+.[0-9]+' - '[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+' + - 'zainod-[0-9]+.[0-9]+.[0-9]+' workflow_dispatch: inputs: push: @@ -33,7 +42,7 @@ jobs: - name: Determine Rust version from rust-toolchain.toml run: | - echo "RUST_VERSION=$(cargo run -q --manifest-path tools/workbench/Cargo.toml --bin get-rust-version)" >> $GITHUB_ENV + echo "RUST_VERSION=$(cargo run -q --manifest-path tools/workbench/Cargo.toml --bin get-rust-version)" >> "$GITHUB_ENV" - name: Extract metadata for Docker (default image) id: meta-default @@ -44,6 +53,7 @@ jobs: ${{ secrets.DOCKERHUB_USERNAME }}/zainod tags: | type=semver,pattern={{version}} + type=match,pattern=zainod-(.*),group=1 type=ref,event=branch type=sha,prefix=,format=short @@ -75,6 +85,7 @@ jobs: suffix=-no-tls tags: | type=semver,pattern={{version}} + type=match,pattern=zainod-(.*),group=1 type=ref,event=branch type=sha,prefix=,format=short @@ -100,9 +111,11 @@ jobs: needs: docker name: Create GitHub Release runs-on: ubuntu-latest - # Only create a GitHub release when triggered by a real semver tag push. - # workflow_dispatch (e.g. for verification on a feature branch) skips this. - if: startsWith(github.ref, 'refs/tags/') + # Only create a GitHub release when triggered by a legacy semver tag push. + # workflow_dispatch (e.g. for verification on a feature branch) skips this, + # and so does a pipeline `zainod-X.Y.Z` tag: blessing already created the + # cycle's GitHub Release. + if: startsWith(github.ref, 'refs/tags/') && !startsWith(github.ref, 'refs/tags/zainod-') steps: - uses: actions/checkout@v7 with: @@ -115,7 +128,7 @@ jobs: if [ -z "$PREVIOUS_TAG" ]; then git log --pretty=format:"* %s" > CHANGELOG.md else - git log --pretty=format:"* %s" $PREVIOUS_TAG..HEAD > CHANGELOG.md + git log --pretty=format:"* %s" "$PREVIOUS_TAG"..HEAD > CHANGELOG.md fi - name: Create Release diff --git a/.gitignore b/.gitignore index d7bffe13..b32e0c85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,17 @@ /target -# Cargo build output. The live-test crates are now members of the root -# workspace (single committed Cargo.lock), so there are no nested workspaces or -# per-workspace lockfiles to ignore. +# Cargo build output. live-tests/ is a separate standalone workspace with its +# own committed Cargo.lock (both lockfiles are tracked on purpose); only the +# build directories are ignored. target/ /db /docs/cargo_docs/debug /.helix /live-tests/chain_cache/* -/live-tests/test_binaries/bins/* /live-tests/target/* !/live-tests/chain_cache/testnet_get_subtree_roots_sapling !/live-tests/chain_cache/testnet_get_subtree_roots_orchard .cargo .local/ build/ -.failed-tests **/proptest-regressions/** /result diff --git a/.release/gate-suites.toml b/.release/gate-suites.toml new file mode 100644 index 00000000..bfcd7a1b --- /dev/null +++ b/.release/gate-suites.toml @@ -0,0 +1,59 @@ +# Gate suite membership — the single source of truth for which tests constitute +# each nextest-realized gate. Authored HERE; read by whatever runs the suite +# (`ztest`, a self-hosted fleet, or CI). The runner resolves a gate name to its +# `filterset` and executes it; it owns no membership policy. See +# docs/release/pipeline.md § "Suite membership". +# +# filterset — one cargo-nextest --filterset expression for the whole tier. +# extends — inherit the named tier's selection (the cumulative property): +# the runner runs this tier's filterset UNIONED with the parent's. +# +# NOTE: no consumer reads this yet — it is wired when the runner lands. It is +# committed now as the authored decision, so the categorization is data (one +# file) rather than scattered across workflow YAML. +# +# ---- classification basis (first cut, 2026-08) ------------------------------ +# The dev/rc split follows the dev-gate's two hard constraints: a wall-clock +# ceiling (~10-15 min, INCLUDING any fixture/snapshot provisioning) and +# non-flakiness. EVERY clientless/e2e binary launches a real validator, so the +# hermetic base of dev-gate is the production (default-member) crates; a live +# binary earns a dev-gate slot only as a curated smoke. +# +# dev-gate smoke — small, regtest-mined, no snapshot dependency, deterministic: +# clientless::test_vectors, clientless::validator_heights, +# clientless::compact_block_consistency, e2e::compact_block_wire +# +# rc-gate — heavy, or depends on a provisioned testnet snapshot, or large: +# clientless::fetch_service (~35 tests), clientless::state_service (~75; +# cached-testnet + regtest mix), clientless::the_pub_testnet_ironwood_boundary +# (loads a local testnet snapshot; SKIPS without one -> no pre-merge signal), +# e2e::test_vectors, e2e::ironwood_activation, e2e::devtool (~170 tests). +# The caches are LOCAL snapshot dirs (deterministic, not network-flaky); the +# reason these are rc-tier is snapshot-provisioning cost, not flakiness. +# +# DISABLED debt (not a tier decision): clientless::chain_cache hangs pending +# chain-index integration -> zingolabs/zaino#1312. Excluded from BOTH tiers +# until fixed, tracked as disabled, never relabelled up a tier to hide a hang. +# +# zcashd_support feature axis (orthogonal): clientless::json_server and +# e2e::devtool_zcashd are `#![cfg(feature = "zcashd_support")]`; the default +# --no-default-features build holds zero of their tests. They run only in a +# zcashd-feature build (rc-tier by size there); the filtersets below target +# the default build and do not touch them. +# +# Deltas from today's CI-PR set: state_service, fetch_service, and +# the_pub_testnet_ironwood_boundary move OUT of pre-merge (size / snapshot +# dependency); e2e::compact_block_wire moves IN (small regtest smoke, formerly +# lumped into the capacity-excluded e2e group, zingolabs/zaino#1308). A finer +# within-binary split (keeping the regtest subset of state_service / +# fetch_service as smoke) is deferred to per-test filtersets once real +# wall-clock data exists. +# ----------------------------------------------------------------------------- + +[dev-gate] +filterset = "(!package(clientless) & !package(e2e)) | binary_id(=clientless::test_vectors) | binary_id(=clientless::validator_heights) | binary_id(=clientless::compact_block_consistency) | binary_id(=e2e::compact_block_wire)" + +[rc-gate] +extends = "dev-gate" +# The full live suite minus the disabled-for-hang binary (zingolabs/zaino#1312). +filterset = "(package(clientless) | package(e2e)) & !binary_id(=clientless::chain_cache)" diff --git a/CHANGELOG.md b/CHANGELOG.md index b9956b01..7b651121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ and this library adheres to Rust's notion of ## Unreleased ### Added +- **`zaino-bench`** — a benchmark harness answering three operational questions + against a running zainod, from the outside, over the interfaces a real client + uses. A workspace member but not a `default-member`, so a bare + `cargo nextest run` never builds it; select it with `makers bench` or + `-p zaino-bench`. + - `sync` — initial-sync time, sampled from zainod's existing Prometheus + endpoint (`zaino.sync.*`). No new instrumentation: it reads what + `zaino-state` already emits behind the `prometheus` feature, with a unit + test pinning the metric names to `zaino_state::metric_names` so a rename + fails the build rather than the run. `--csv` writes the sync curve. + - `concurrent` — concurrent-connection load test with a `--sweep` mode that + locates the knee, tail percentiles (p50/p95/p99) alongside min/mean/max, and + an `RLIMIT_NOFILE` preflight so a client-side `ulimit` is not mistaken for a + server-side ceiling. Ported from the `zaino-admin concurrent-test` tool on + the `hahn/store` branch. + - `serve` — single-stream block serve rate, verifying every `prev_hash` link + in the same pass. Ported from that branch's `zaino-admin check`. + - `docs/perf.md` records the results and, next to them, the machine spec and + node configs that produced them: + `docs/example_configs/zainod-bench-mainnet{,-ephemeral}.toml`. Both select + `backend = 'direct'` deliberately — the fastest path Zaino has, and so the + honest ceiling to quote — and differ only in + `ephemeral_finalised_state`. `concurrent` and `serve` are reported under + both modes, since a finalised read is answered from Zaino's own index in one + and by passthrough to the validator in the other; `sync` is persistent-only. - **Eight new crates** implementing validator access as a hexagonal port / adapter stack (ADR-0008, ADR-0009). Each carries a `usage.md`: - `zaino-primitives` — Zaino's domain vocabulary. Depends on `thiserror` and @@ -31,6 +56,19 @@ and this library adheres to Rust's notion of validator's bytes and never parse them. - `zaino-mempool-service` — the runtime: the polling core, the read handles, and the tip-aware coherence layer. +- **Two more crates for the chain head subsystem** (ADR-0011), replacing + `zaino-state`'s `non_finalised_state` module: + - `zaino-chain-head` — the domain types and ports for the bounded, + non-finalised head of the chain. No runtime and no data structures: the + graph's representation belongs to whoever publishes it. + - `zaino-chain-head-service` — the runtime: the writer task that keeps the + graph reconciled with the validator, and the snapshots it publishes. + + The behavioural change this buys: the chain head and the finalised state now + advance independently, so a slow database no longer holds the chain tip back. + In exchange `ChainIndex::new` fails when the chain head cannot anchor, where + the old code retried in the background indefinitely and served a "still + syncing" case from every read path. - Three mempool sourcing ports in `zaino-source` — `GetMempoolMetadata`, `GetRawMempoolTransaction`, `GetMempoolSourceTip` — all of which an adapter must route to the same transport as `GetMempoolTxids`. @@ -38,6 +76,75 @@ and this library adheres to Rust's notion of cadence and exclude-list caps operator-configurable. ### Changed +- **LMDB reader slots raised from 512 to 2048–8192.** The clamp was + `(cpu * 32).clamp(512, 4096)`, which gives exactly 512 — the floor — on any + host with 16 cores or fewer. With `NO_TLS` a slot belongs to a read + *transaction* rather than a thread, so 512 is a hard ceiling on concurrent + reads, and an ordinary concurrency benchmark exhausted it: reads failed with + `MDB_READERS_FULL`, the startup block scan treats that as fatal, and the node + restarted in a loop. A slot is one cache line (the measured `lock.mdb` is + 32,896 bytes at 512 readers), so 8192 slots costs ~512 KiB of shared memory. + + **This does not make exhaustion safe.** A client can still open more + concurrent reads than there are slots. `MDB_READERS_FULL` being classified as + a critical error — rather than the backpressure it is — remains an open bug, + and until it is fixed a client can restart a node by exceeding whatever limit + is configured. +- **Bulk finalised-state sync now assembles blocks concurrently too.** Making + the fetch concurrent exposed the other half: a profile through the sandblast + heights put **54% of all CPU on a single thread**, holding the run to 8 + blocks/s on 1.8 of 16 cores. That thread was the chainwork fold, which ran + `assemble_indexed_block` in block order. Assembly is as expensive as the + fetch and for the mirror-image reason — converting to the compact form + re-serialises the Jubjub points zebra just decompressed, and returning to + affine coordinates costs a field inversion per point. By Amdahl that capped + the whole run at 1.85x however many cores the fetches used. + + A block's own proof-of-work depends only on its own header, so the cumulative + chainwork is a running sum that can be folded over already-fetched blocks in a + separate pass. Bulk sync is now three: fetch a window concurrently, fold the + chainwork in order (integer arithmetic, microseconds), then assemble the + window concurrently on `spawn_blocking`. The ordering guarantee is unchanged — + every block still gets exactly its parent's chainwork plus its own. +- **Bulk finalised-state sync now fetches blocks concurrently.** A profile of a + mainnet sync through the sandblast heights (~1.7M) put **91% of cycles in + BLS12-381 scalar arithmetic** — `sqrt_tonelli_shanks`, `Scalar::square`, + `Scalar::invert` — against **1.3% in LMDB**. That is Jubjub point + decompression: zebra's block deserializer resolves `cv` and `ephemeral_key` + for every Sapling output via `from_bytes_not_small_order` (a modular square + root plus a cofactor multiplication), and Zaino discards all of it, keeping + only the compact representation. Sandblast-era blocks carry hundreds of + outputs each, which took block building from 1.4ms to 200ms per block — a + sync doing 5 blocks/s on one core with fifteen idle. + + The fetch does not depend on `parent_chainwork`, so it no longer runs in block + order: `write_blocks_to_height` now issues one fetch per core (less one, capped + at 16) and folds the results back in height order. The read-state service runs + each read on `spawn_blocking`, so the decompression spreads across cores. The + order-dependent half — chaining `parent_chainwork` — is unchanged. + + This divides the waste rather than removing it. The fix that removes it is + upstream in zebra: decompress `cv`/`ephemeral_key` lazily, since reading a + historical block never verifies it. + + `zaino.sync.block_build_seconds` still records per-block cost, so with N + fetches in flight its sum approaches N x wall-clock; divide by the concurrency + before comparing it against elapsed time. +- **Bulk finalised-state sync now pipelines its write batches.** + `DbV1::write_blocks_to_height` commits batch N on a scoped thread while it + builds batch N+1, instead of alternating between the two. Measured on a + mainnet sync at height ~1.2M, the serial form spent 60% of wall-clock building + blocks and 40% inside `write_block_batch_blocking` + `env.sync`, with the + builder idle for every commit — a mean 79s pause every 120s. Overlapping them + hides the shorter half behind the longer. + + Durability and resume semantics are unchanged: a batch is still written in one + transaction and fsynced before the validated tip advances, so the on-disk + `headers` tip never runs ahead of the indexes. The one operational change is + memory — peak heap for buffered blocks is now up to *twice* + `storage.database.sync_write_batch_size`, since the batch being committed is + still resident while its successor fills. That knob bounds one batch, not the + pipeline; size it for the host accordingly. - **The mempool no longer stalls across a tip transition.** `getrawmempool`, `getmempoolinfo` and `GetMempoolTx` are served from a tip-agnostic set that never clears; the old mempool wiped its whole map on every tip change and @@ -130,6 +237,14 @@ and this library adheres to Rust's notion of gates code (ADR-0001, ADR-0005). ### Fixed +- `GetBlock` and `GetBlockNullifiers` served every pool unconditionally, while + `GetBlockRange`/`GetBlockRangeNullifiers` honoured the request's `poolTypes` + and default to the legacy shielded-only set. The same height therefore came + back with different contents depending on which RPC asked for it — a + transparent-only transaction was present in the single-block form and absent + from the range form. `BlockID` carries no `poolTypes` field, so both + single-block RPCs now serve the unfiltered default, matching both the range + form and lightwalletd. - JSON-RPC responses are read against a 32 MiB cap, chunk-wise. Every response is deserialized into memory, so an uncapped read let a compromised, misconfigured or impersonated validator exhaust Zaino's memory with one reply. @@ -153,6 +268,8 @@ and this library adheres to Rust's notion of - `getblockdeltas` is served on zebrad-backed deployments. zebrad does not implement the method, and the read-state derivation that answered it had been omitted on the mistaken reasoning that the validator already provided it. +- added `getaddressdeltas` stub to json-rpc server +- Errors relayed from backing validator properly propagate the error message - `getblockchaininfo` and `z_getblock` work against zebra 6.0, which serialises the deferred-development-fund value pool as `lockbox` where zcashd calls it `deferred`. diff --git a/CLAUDE.md b/CLAUDE.md index 00e4eb32..79ebfd76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,9 +18,9 @@ most restrictive visibility that compiles. Start with no visibility qualifier scope that works: 1. `(private)` — default, no qualifier -2. `pub(super)` — visible to the parent module -3. `pub(crate)` — visible within the crate -4. `pub` — visible to external consumers +1. `pub(super)` — visible to the parent module +1. `pub(crate)` — visible within the crate +1. `pub` — visible to external consumers Never preemptively make something `pub`. If a test needs access to an internal, prefer `pub(crate)` or a `#[cfg(test)]` helper over `pub`. If an item is only @@ -40,8 +40,8 @@ Every test starts at `#[test]`. Escalate only when the test body demands it, and pick the narrowest escalation that works: 1. `#[test]` — default. Synchronous tests. -2. `#[tokio::test]` (current-thread) — the test body actually uses `.await`. -3. `#[tokio::test(flavor = "multi_thread")]` — the test genuinely requires +1. `#[tokio::test]` (current-thread) — the test body actually uses `.await`. +1. `#[tokio::test(flavor = "multi_thread")]` — the test genuinely requires multiple OS threads (a race under test, `spawn_blocking` that must run concurrently with the test future, code that would deadlock on current-thread). @@ -78,9 +78,9 @@ in the same directory need access. 1. The `PersistentX → X` direction *is* the validation step for bytes coming off disk. A named method puts that contract in the API; a `TryFrom` leaves it implicit. -2. `TryFrom` forces one `Error` type per impl; separate methods give +1. `TryFrom` forces one `Error` type per impl; separate methods give per-conversion error granularity. -3. Named methods are grep-friendly and disambiguate direction at every +1. Named methods are grep-friendly and disambiguate direction at every call site (`pbc.into_business()` reads direction and boundary; `.into()` hides both). @@ -126,14 +126,14 @@ TransactionHash, etc.). 1. No `impl From<&X> for PersistentY` / `impl From for Y`; no `impl From for proto::Y` / `impl TryFrom for Y`. (The lint catches these, but read for them anyway.) - 2. Persistence methods are named `from_business` / `into_business` + 1. Persistence methods are named `from_business` / `into_business` (fallible variants `into_business*`). Wire methods are named `to_wire` / `try_from_wire`. Any deviation has an in-file comment explaining why. - 3. `Persistent*` types are `pub(super)`. Wire methods are `pub` + 1. `Persistent*` types are `pub(super)`. Wire methods are `pub` (they're part of the business type's public API). Don't widen `Persistent*` speculatively. - 4. `Persistent*` types do *nothing else* — no business logic, no + 1. `Persistent*` types do *nothing else* — no business logic, no accessors — they only cross the serde boundary. Round-trip tests for the pair live in the same file under `#[cfg(test)] mod tests`. Wire conversions get the same treatment: a golden / round-trip @@ -154,10 +154,10 @@ constraints: encoded in the type system or recovered from at runtime (e.g. a `Mutex` that is only ever held for a non-panicking swap, so `PoisonError` indicates an already-crashed thread). -2. The message names the invariant being asserted, so a panic message +1. The message names the invariant being asserted, so a panic message is self-describing (e.g. `.expect("db_handler mutex poisoned")`, not `.expect("unwrap")`). -3. Propagation via `?` or a typed error is not cleaner at the call +1. Propagation via `?` or a typed error is not cleaner at the call site. If the surrounding function already returns a `Result`, prefer `?`. @@ -182,18 +182,6 @@ Reach for `grep` only as a fallback — when the server is genuinely unavailable, still indexing, or the target isn't code it understands — and say so when you do. -**Single-workspace note (this repo):** the tree is one Cargo workspace. The -root `Cargo.toml` holds the `packages/*` production crates **and** the live-test -crates (`live-tests/{e2e,clientless,zaino-testutils}`) as members (see -docs/adr/0002, docs/adr/0003, docs/adr/0004). `default-members` is the -production set, so a bare `cargo` / `cargo nextest run` builds and tests only -those and excludes the heavy live-test crates; the live suite is selected -explicitly with `-p e2e` / `-p clientless` (the `makers test` front door). -rust-analyzer indexes the -single workspace, so go-to-def / find-references resolve across production and -test code in one pass — no linked-project swapping, and an empty result means -"no references," not "the other workspace isn't loaded." - ## Crate usage guides: keep them current Each crate is documented by a usage guide at `packages//usage.md`, indexed diff --git a/CONTEXT.md b/CONTEXT.md index c8e49a6c..d298cced 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,8 +14,8 @@ hard-coded list. _Avoid_: crate list, publish list **Blocking context**: -A CI context in which release checks must pass: pushes to `rc/**` or -`stable`, and pull requests targeting them. +A CI context in which release checks must pass: pushes to `stable`, whose +tip is the release commit. _Avoid_: strict mode, release mode **Advisory context**: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb3f09e2..01f91e9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ Error handling must be included and expose underlying information as much as and Merges must minimally reflect the zcash RPC spec and include a link to the relevant zcash C++ implementation (URLs that point at the analogous logic), OR reflect the C++ implementation. -Tests are encouraged that show parity bewteen responses from `zcash-cli` + `zcashd` and `zaino`+ a `zebra` backend, and the local cache. +Tests are encouraged that show parity between responses from `zaino` + a `zebra` backend and the local cache. ## Local Testing Local testing requires a system with ample resources, particularly RAM. diff --git a/Cargo.lock b/Cargo.lock index e7323ecb..ba4fcb0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -686,33 +686,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "clientless" -version = "0.2.0" -dependencies = [ - "anyhow", - "futures", - "hex", - "serde_json", - "tokio", - "wire_serialized_transaction_test_data", - "zaino-address", - "zaino-common", - "zaino-consensus", - "zaino-primitives", - "zaino-proto", - "zaino-rpc", - "zaino-serve", - "zaino-state", - "zaino-status", - "zaino-testutils", - "zainod", - "zcash_local_net", - "zebra-chain", - "zebra-rpc", - "zebra-state", -] - [[package]] name = "cmake" version = "0.1.58" @@ -1184,31 +1157,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "e2e" -version = "0.2.0" -dependencies = [ - "corez", - "futures", - "hex", - "serde_json", - "tokio", - "tower 0.4.13", - "zaino-common", - "zaino-consensus", - "zaino-primitives", - "zaino-proto", - "zaino-serve", - "zaino-state", - "zaino-testutils", - "zainod", - "zcash_local_net", - "zcash_primitives", - "zebra-chain", - "zebra-rpc", - "zebra-state", -] - [[package]] name = "ed25519" version = "2.2.3" @@ -3307,7 +3255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools 0.13.0", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -3328,7 +3276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.118", @@ -4228,6 +4176,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_arrays" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a16b99c5ea4fe3daccd14853ad260ec00ea043b2708d1fd1da3106dcd8d9df" +dependencies = [ + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5824,12 +5781,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wire_serialized_transaction_test_data" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebb4f5d78d0832cc4a1760a20288470c6fafa02f18e555a7ece133cd8cef5ca" - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -5894,7 +5845,7 @@ dependencies = [ [[package]] name = "zaino-address" -version = "0.1.0" +version = "0.1.1" dependencies = [ "hex", "sapling-crypto", @@ -5905,9 +5856,112 @@ dependencies = [ "zcash_transparent", ] +[[package]] +name = "zaino-bench" +version = "0.1.0" +dependencies = [ + "clap", + "futures", + "reqwest 0.13.1", + "thiserror 2.0.18", + "tokio", + "tonic", + "zaino-common", + "zaino-proto", + "zaino-state", +] + +[[package]] +name = "zaino-chain-head" +version = "0.1.1" +dependencies = [ + "thiserror 2.0.18", + "tokio", + "zaino-consensus", + "zaino-primitives", + "zaino-source", + "zaino-source-zebra", +] + +[[package]] +name = "zaino-chain-head-service" +version = "0.1.1" +dependencies = [ + "arc-swap", + "futures", + "metrics", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "zaino-chain-head", + "zaino-consensus", + "zaino-primitives", + "zaino-source", + "zaino-status", +] + +[[package]] +name = "zaino-chain-store" +version = "0.1.0" +dependencies = [ + "blake2", + "futures", + "thiserror 2.0.18", + "tokio", + "zaino-primitives", + "zaino-source", + "zaino-source-zebra", + "zaino-status", +] + +[[package]] +name = "zaino-chain-store-zainodb" +version = "0.1.0" +dependencies = [ + "arc-swap", + "bitflags 2.13.0", + "blake2", + "corez", + "dashmap", + "futures", + "hex", + "lmdb", + "lmdb-sys", + "metrics", + "primitive-types 0.14.0", + "proptest", + "serde", + "serde_arrays", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tracing", + "tracing-subscriber", + "zaino-chain-store", + "zaino-common", + "zaino-consensus", + "zaino-convert-zebra", + "zaino-encoding", + "zaino-primitives", + "zaino-proto", + "zaino-source", + "zaino-source-zebra", + "zaino-status", + "zcash_primitives", + "zcash_protocol", + "zebra-chain", + "zebra-rpc", +] + [[package]] name = "zaino-common" -version = "0.5.0" +version = "0.5.2" dependencies = [ "rustls", "serde", @@ -5920,7 +5974,7 @@ dependencies = [ [[package]] name = "zaino-consensus" -version = "0.1.0" +version = "0.1.1" dependencies = [ "hex", "primitive-types 0.14.0", @@ -5929,7 +5983,7 @@ dependencies = [ [[package]] name = "zaino-convert-zebra" -version = "0.1.0" +version = "0.2.1" dependencies = [ "thiserror 2.0.18", "zaino-consensus", @@ -5938,8 +5992,15 @@ dependencies = [ ] [[package]] -name = "zaino-mempool" +name = "zaino-encoding" version = "0.1.0" +dependencies = [ + "corez", +] + +[[package]] +name = "zaino-mempool" +version = "0.2.1" dependencies = [ "bytes", "futures", @@ -5951,7 +6012,7 @@ dependencies = [ [[package]] name = "zaino-mempool-service" -version = "0.1.0" +version = "0.2.1" dependencies = [ "arc-swap", "async-stream", @@ -5969,14 +6030,14 @@ dependencies = [ [[package]] name = "zaino-primitives" -version = "0.1.0" +version = "0.2.1" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "zaino-proto" -version = "0.4.0" +version = "0.6.0" dependencies = [ "prost", "tonic", @@ -5989,7 +6050,7 @@ dependencies = [ [[package]] name = "zaino-rpc" -version = "0.1.0" +version = "0.2.1" dependencies = [ "reqwest 0.13.1", "serde", @@ -6003,7 +6064,7 @@ dependencies = [ [[package]] name = "zaino-serve" -version = "0.6.0" +version = "0.8.0" dependencies = [ "futures", "hex", @@ -6031,16 +6092,26 @@ dependencies = [ [[package]] name = "zaino-source" -version = "0.1.0" +version = "0.2.1" dependencies = [ "thiserror 2.0.18", "tokio", "zaino-primitives", + "zaino-source-macros", ] [[package]] -name = "zaino-source-zebra" +name = "zaino-source-macros" version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zaino-source-zebra" +version = "0.2.1" dependencies = [ "tokio", "zaino-primitives", @@ -6051,7 +6122,7 @@ dependencies = [ [[package]] name = "zaino-source-zebra-readstate" -version = "0.1.0" +version = "0.2.1" dependencies = [ "chrono", "serde_json", @@ -6068,7 +6139,7 @@ dependencies = [ [[package]] name = "zaino-source-zebra-rpc" -version = "0.1.0" +version = "0.2.1" dependencies = [ "hex", "incrementalmerkletree", @@ -6088,10 +6159,9 @@ dependencies = [ [[package]] name = "zaino-state" -version = "0.7.0" +version = "0.9.0" dependencies = [ "arc-swap", - "bitflags 2.13.0", "blake2", "bytes", "chrono", @@ -6101,8 +6171,6 @@ dependencies = [ "hex", "incrementalmerkletree", "indexmap 2.14.0", - "lmdb", - "lmdb-sys", "metrics", "primitive-types 0.14.0", "proptest", @@ -6124,9 +6192,14 @@ dependencies = [ "tracing-subscriber", "whoami", "zaino-address", + "zaino-chain-head", + "zaino-chain-head-service", + "zaino-chain-store", + "zaino-chain-store-zainodb", "zaino-common", "zaino-consensus", "zaino-convert-zebra", + "zaino-encoding", "zaino-mempool", "zaino-mempool-service", "zaino-primitives", @@ -6139,7 +6212,6 @@ dependencies = [ "zaino-status", "zcash_address", "zcash_keys", - "zcash_primitives", "zcash_protocol", "zcash_transparent", "zebra-chain", @@ -6149,44 +6221,14 @@ dependencies = [ [[package]] name = "zaino-status" -version = "0.1.0" -dependencies = [ - "tracing", -] - -[[package]] -name = "zaino-testutils" -version = "0.2.0" +version = "0.1.1" dependencies = [ - "futures", - "hex", - "http", - "once_cell", - "prost", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tonic", "tracing", - "wire_serialized_transaction_test_data", - "zaino-common", - "zaino-primitives", - "zaino-proto", - "zaino-rpc", - "zaino-serve", - "zaino-state", - "zaino-status", - "zainod", - "zcash_local_net", - "zcash_protocol", - "zebra-chain", - "zebra-rpc", - "zebra-state", ] [[package]] name = "zainod" -version = "0.8.0" +version = "0.10.0" dependencies = [ "clap", "config", @@ -6199,6 +6241,7 @@ dependencies = [ "tokio", "toml", "tracing", + "zaino-chain-head-service", "zaino-common", "zaino-mempool", "zaino-rpc", @@ -6274,24 +6317,6 @@ dependencies = [ "zip32", ] -[[package]] -name = "zcash_local_net" -version = "0.7.0" -source = "git+https://github.com/zingolabs/infrastructure.git?rev=85502cd0b5faf9308e590865dae51bf31fdd342e#85502cd0b5faf9308e590865dae51bf31fdd342e" -dependencies = [ - "hex", - "reqwest 0.12.28", - "serde", - "serde_json", - "sha2 0.10.9", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tracing", - "zingo-consensus", - "zingo_test_vectors", -] - [[package]] name = "zcash_note_encryption" version = "0.4.1" @@ -6825,16 +6850,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "zingo-consensus" -version = "0.1.0" -source = "git+https://github.com/zingolabs/infrastructure.git?rev=85502cd0b5faf9308e590865dae51bf31fdd342e#85502cd0b5faf9308e590865dae51bf31fdd342e" - -[[package]] -name = "zingo_test_vectors" -version = "0.0.1" -source = "git+https://github.com/zingolabs/infrastructure.git?rev=85502cd0b5faf9308e590865dae51bf31fdd342e#85502cd0b5faf9308e590865dae51bf31fdd342e" - [[package]] name = "zip32" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 313b895b..45fb99fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,10 @@ members = [ "packages/zaino-status", "packages/zaino-consensus", + "packages/zaino-encoding", "packages/zaino-primitives", "packages/zaino-address", + "packages/zaino-source-macros", "packages/zaino-source", "packages/zaino-rpc", "packages/zaino-convert-zebra", @@ -14,24 +16,35 @@ members = [ "packages/zaino-mempool", "packages/zaino-mempool-service", "packages/zaino-common", + "packages/zaino-chain-head", + "packages/zaino-chain-head-service", + "packages/zaino-chain-store", + "packages/zaino-chain-store-zainodb", "packages/zainod", "packages/zaino-serve", "packages/zaino-state", "packages/zaino-proto", - # Live-test suite (docs/adr/0002, docs/adr/0003, docs/adr/0004). - # zaino-testutils first: the e2e/clientless partitions depend on it. - "live-tests/zaino-testutils", - "live-tests/clientless", - "live-tests/e2e", + # Benchmark harness. Excluded from `default-members` for the same reason as + # the live-test crates: it is an operator tool run explicitly + # (`-p zaino-bench`), never part of the fast test loop. + "packages/zaino-bench", + # NOTE: live-tests/ is its own standalone workspace (see + # live-tests/Cargo.toml). It has been migrated onto the ztest k8s harness + # (`ztest` from crates.io), which stands the system up as deployed images and + # drives it over its served interfaces, so the suite links no production + # crate and shares no lockfile with one. Build and run it from live-tests/. ] -# `container-test` runs a bare `cargo nextest run`, which operates on -# default-members — so the heavy live-test crates are built and tested only -# when selected explicitly (`-p e2e` / `-p clientless`). See docs/adr/0002. +# `default-members` == `members` here (the live-test crates are a separate +# standalone workspace under live-tests/, not members). A bare `cargo nextest +# run` therefore tests exactly the production crates; the live suite runs from +# live-tests/ on the ztest harness (`cd live-tests && ztest run`). default-members = [ "packages/zaino-status", "packages/zaino-consensus", + "packages/zaino-encoding", "packages/zaino-primitives", "packages/zaino-address", + "packages/zaino-source-macros", "packages/zaino-source", "packages/zaino-rpc", "packages/zaino-convert-zebra", @@ -41,6 +54,10 @@ default-members = [ "packages/zaino-mempool", "packages/zaino-mempool-service", "packages/zaino-common", + "packages/zaino-chain-head", + "packages/zaino-chain-head-service", + "packages/zaino-chain-store", + "packages/zaino-chain-store-zainodb", "packages/zainod", "packages/zaino-serve", "packages/zaino-state", @@ -135,68 +152,83 @@ bitflags = "2.9" # Test tempfile = "3.2" -# `zcashd_support` is opt-in, not a default (docs/adr/0005), so these path deps -# no longer need `default-features = false` to drop it: their default feature -# set is already empty. Enable zcashd end-to-end with `--features zcashd_support`. zainod = { path = "packages/zainod" } # Service status and health-probe vocabulary. A leaf with one dependency # (`tracing`), so any subsystem can report its status without inheriting a # dependency graph to do it. -zaino-status = { path = "packages/zaino-status", version = "0.1.0" } +zaino-status = { path = "packages/zaino-status", version = "0.1.1" } # Consensus-derived constants (reorg bound, coinbase maturity, block size limit) # and the protocol-limit validation built on them. A leaf that depends on no # node implementation, so referencing a consensus fact costs neither a # general-purpose dependency nor a peer's reading of the specification. -zaino-consensus = { path = "packages/zaino-consensus", version = "0.1.0" } +zaino-consensus = { path = "packages/zaino-consensus", version = "0.1.1" } +# Zaino's versioned encoding: the `ZainoVersionedSerde` / `FixedEncodedLen` +# traits, `CompactSize`, and the little/big-endian byte helpers the on-disk +# formats are written in. A leaf over `corez` alone, so a storage backend and a +# wire codec can share one encoding vocabulary without either depending on the +# other. Holds no impl for any domain type: those live with the type whose +# bytes they describe, which is what keeps a schema out of the vocabulary +# crates. See docs/adr/0012. +zaino-encoding = { path = "packages/zaino-encoding", version = "0.1.0" } # Zero-dependency domain vocabulary. Every crate that needs chain-level types # (heights, hashes, blocks, transactions) depends on this rather than on # `zebra-chain` or on another zaino crate. See docs/adr/0010. -zaino-primitives = { path = "packages/zaino-primitives", version = "0.1.0" } +zaino-primitives = { path = "packages/zaino-primitives", version = "0.2.1" } # Zcash address parsing and classification, for the two address-validation RPCs. # A leaf: it isolates the librustzcash address stack from everything else. -zaino-address = { path = "packages/zaino-address", version = "0.1.0" } +zaino-address = { path = "packages/zaino-address", version = "0.1.1" } # Driven port traits for validator access: one trait per question a consumer can # ask, so each consumer's bound names exactly the capabilities it uses and an # adapter that cannot answer a question simply does not implement it. -zaino-source = { path = "packages/zaino-source", version = "0.1.0" } -zaino-mempool = { path = "packages/zaino-mempool", version = "0.1.0" } -zaino-mempool-service = { path = "packages/zaino-mempool-service", version = "0.1.0" } +zaino-source = { path = "packages/zaino-source", version = "0.2.1" } +zaino-source-macros = { path = "packages/zaino-source-macros", version = "0.1.0" } +zaino-mempool = { path = "packages/zaino-mempool", version = "0.2.1" } +zaino-mempool-service = { path = "packages/zaino-mempool-service", version = "0.2.1" } # JSON-RPC 2.0 transport shared by the RPC-backed source adapters. -zaino-rpc = { path = "packages/zaino-rpc", version = "0.1.0" } +zaino-rpc = { path = "packages/zaino-rpc", version = "0.2.1" } # The single place `zebra_chain` types are translated into domain types. -zaino-convert-zebra = { path = "packages/zaino-convert-zebra", version = "0.1.0" } +zaino-convert-zebra = { path = "packages/zaino-convert-zebra", version = "0.2.1" } # Source adapters. The RPC adapter answers every question over the Zcash # JSON-RPC interface; the ReadState adapter answers the subset zebra's # in-process state can serve, and simply does not implement the rest. -zaino-source-zebra-rpc = { path = "packages/zaino-source-zebra-rpc", version = "0.1.0" } -zaino-source-zebra-readstate = { path = "packages/zaino-source-zebra-readstate", version = "0.1.0" } +zaino-source-zebra-rpc = { path = "packages/zaino-source-zebra-rpc", version = "0.2.1" } +zaino-source-zebra-readstate = { path = "packages/zaino-source-zebra-readstate", version = "0.2.1" } # Composite over the two adapters: capability decides what each can answer, # this decides which to ask. -zaino-source-zebra = { path = "packages/zaino-source-zebra", version = "0.1.0" } -zaino-common = { path = "packages/zaino-common", version = "0.5.0" } -zaino-proto = { path = "packages/zaino-proto", version = "0.4.0" } -zaino-state = { path = "packages/zaino-state", version = "0.7.0", default-features = false } -zaino-serve = { path = "packages/zaino-serve", version = "0.6.0", default-features = false } -zaino-testutils = { path = "live-tests/zaino-testutils" } - -# Zingo-infra (validator launcher driving the live tests; not a prod dep). -# Pinned to an exact commit past zcash_local_net_v0.7.0: the v0.7.0 launcher's -# zebrad config writer silently drops NU6.3 activation heights -# (https://github.com/zingolabs/zaino/issues/1368). This rev additionally -# makes the Validator the sole compile-time source of activation-height -# truth (infrastructure ADR 0003, PR #278): wallet clients derive their -# heights via `WalletNetwork::from_validator`, and `ZainodConfig` carries a -# payload-free network kind. Restore a release tag once one is cut upstream. -zcash_local_net = { git = "https://github.com/zingolabs/infrastructure.git", rev = "85502cd0b5faf9308e590865dae51bf31fdd342e" } - -wire_serialized_transaction_test_data = "1.0.0" +zaino-source-zebra = { path = "packages/zaino-source-zebra", version = "0.2.1" } +zaino-common = { path = "packages/zaino-common", version = "0.5.2" } +# ChainHead: the bounded non-finalised block graph, its canonical chain, and the +# competing branches retained alongside it. This crate is the domain half — +# types and ports only, no runtime — so a consumer can name what ChainHead +# answers without depending on the service that answers it. See docs/adr/0011. +zaino-chain-head = { path = "packages/zaino-chain-head", version = "0.1.1" } +# The ChainHead runtime: owns the writer task that keeps the graph reconciled +# with the validator, and publishes each snapshot. Separate from the domain +# crate so a consumer can name what ChainHead answers without depending on the +# machinery that answers it. +zaino-chain-head-service = { path = "packages/zaino-chain-head-service", version = "0.1.1" } +# ChainStore: the finalised half of the chain — everything below the reorg +# seam, and the indexes built over it. This crate is the domain half: types and +# ports only, no storage, so a consumer can name what a store answers without +# depending on the implementation that answers it, and a second implementation +# is a matter of satisfying the traits. It also owns Zaino's UTXO-set +# commitment, which is a contract between implementations rather than any one +# implementation's business. See docs/adr/0012. +zaino-chain-store = { path = "packages/zaino-chain-store", version = "0.1.0" } +# The LMDB implementation of those ports, and the on-disk vocabulary it is +# built from. Named for the backend rather than for the role: a second +# implementation is expected, and neither should be able to claim to be *the* +# chain store. Consumers depend on the ports; this is what a deployment picks. +zaino-chain-store-zainodb = { path = "packages/zaino-chain-store-zainodb", version = "0.1.0" } +zaino-proto = { path = "packages/zaino-proto", version = "0.6.0" } +zaino-state = { path = "packages/zaino-state", version = "0.9.0", default-features = false } +zaino-serve = { path = "packages/zaino-serve", version = "0.8.0", default-features = false } + + config = { version = "0.15", default-features = false, features = ["toml"] } proptest = "~1.11" -anyhow = "1.0" arc-swap = "1.7.1" [profile.test] opt-level = 3 debug = true - -[patch.crates-io] diff --git a/Dockerfile b/Dockerfile index 582cebfa..7fd03f95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ cmake=3.25.1-1 \ make=4.3-4.1 \ ca-certificates=20230311+deb12u1 \ - protobuf-compiler=3.21.12-3 \ + protobuf-compiler=3.21.12-3+deb12u1 \ && rm -rf /var/lib/apt/lists/* # Copy entire workspace (prevents missing members) diff --git a/Makefile.toml b/Makefile.toml index 2bcaec62..cdf7c3cf 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -3,18 +3,13 @@ extend = [ { path = "tools/makefiles/notify.toml" }, { path = "tools/makefiles/rocksdb.toml" }, ] -env_files = [".env.testing-artifacts"] - [config] default_to_workspace = false [env] IMAGE_NAME = "zingodevops/zaino-ci" -TEST_BINARIES_DIR = "/home/container_user/artifacts" -CONTAINERS_CONF_OVERRIDE = "${CARGO_MAKE_WORKING_DIRECTORY}/.config/containers.conf" # Single source of truth: rust-toolchain.toml's `channel`, extracted via the -# workbench `get-rust-version` binary. Replaces RUST_VERSION in -# .env.testing-artifacts. +# workbench `get-rust-version` binary. RUST_VERSION = { script = ["cargo run -q --manifest-path tools/workbench/Cargo.toml --bin get-rust-version"] } [tasks.help] @@ -36,13 +31,6 @@ script.post = "" # ------------------------------------------------------------------- -[tasks.init-podman-volumes] -description = "Initialize named podman volumes for container builds" -script_runner = "bash" -script = 'source "./tools/scripts/init-podman-volumes.sh"' - -# ------------------------------------------------------------------- - [tasks.compute-image-tag] description = "Compute image tag from version vars" script_runner = "bash" @@ -57,7 +45,6 @@ script = 'source "./tools/scripts/get-podman-hash.sh"' [tasks.ensure-image-exists] description = "Ensure the image exists locally, building if needed" -dependencies = ["init-podman-volumes"] script_runner = "bash" extend = "base-script" script.main = 'source "./tools/scripts/ensure-image-exists.sh"' @@ -72,9 +59,10 @@ script.main = 'source "./tools/scripts/pull-ci-image.sh"' # ------------------------------------------------------------------- [tasks.build-image] -description = "Build the container image for testing artifacts" -# Note: This task builds the container image from the live-tests/test_environment -# directory, which contains the Containerfile and entrypoint.sh for the CI/test environment. +description = "Build the CI container image (Rust build environment)" +# Note: builds from live-tests/test_environment/, which holds the Containerfile +# for the CI build-environment image (Rust toolchain + protoc + RocksDB + +# cargo-nextest; no validator binaries — see docs/testing.md). script_runner = "bash" extend = "base-script" script.main = 'source "./tools/scripts/build-image.sh"' @@ -91,177 +79,36 @@ script.main = 'source "./tools/scripts/push-image.sh"' # ------------------------------------------------------------------- -[tasks.container-test] -clear = true -description = "Engine: run nextest in the local CI container, forwarding args (zcashd_support OFF by default; --with-zcashd adds --features zcashd_support). Devs use `makers test [packages|e2e|clientless|live|all]`; live-clientless / live-e2e and the `test` front door call this." -# This task runs tests inside the container built from live-tests/test_environment. -# The entrypoint.sh script in the container sets up test binaries (zcashd, zebrad, zcash-cli) -# by creating symlinks from /home/container_user/artifacts to the expected live-tests/test_binaries/bins location. -dependencies = ["init-podman-volumes", "ensure-image-exists"] -script_runner = "bash" -extend = "base-script" -# The container-test binary (tools/test-runner, a std-only cargo crate — no -# rust-script needed) owns the podman run; `set -e` makes a test failure abort -# the script (so `script.post = notify` is skipped on failure, as before). -script.main = ''' -set -euo pipefail -cargo run -q --manifest-path tools/test-runner/Cargo.toml --bin container-test -- "${@}" -''' -script.post = "makers notify" - -# ------------------------------------------------------------------- - -[tasks.live-clientless] -description = "Run the `clientless` live-test partition inside the CI container (forwards extra args to `cargo nextest run`)" -env = { PACKAGE = "clientless", PACKAGE_DESC = "clientless partition" } -script_runner = "bash" -extend = "base-script" -script.main = 'source "./tools/scripts/container-nextest-workspace.sh"' - -# ------------------------------------------------------------------- - -[tasks.live-e2e] -description = "Run the `e2e` live-test partition inside the CI container (forwards extra args to `cargo nextest run`)" -env = { PACKAGE = "e2e", PACKAGE_DESC = "e2e partition" } -script_runner = "bash" -extend = "base-script" -script.main = 'source "./tools/scripts/container-nextest-workspace.sh"' - -# ------------------------------------------------------------------- - # =================================================================== -# Developer-facing test front door: a single `makers test [SET]` task. -# -# makers test packages (the default) -# makers test packages packages/* production-crate tests (no validator) -# makers test e2e the e2e live partition -# makers test clientless the clientless live partition -# makers test live both live partitions + combined summary -# makers test all packages then live (everything) -# -# SET names mirror the crate/dir names (`e2e`, `clientless`); `packages`, -# `live`, and `all` are the groupings. `live` = all non-packages tests; `all` -# = `packages` + `live` (see live-tests/CONTEXT.md). Each set delegates to an -# existing engine rather than re-implementing a run. `--with-zcashd` (handled -# by the engines) adds the zcashd-backed tests to any set. +# Tripwire, not a front door. `makers test` used to route both suites; it was +# removed. Without this override cargo-make's *builtin* `test` +# task takes the name and silently runs `cargo test`, which skips the live +# suites entirely — a false pass. Fail loudly and name the real commands. # =================================================================== [tasks.test] -# clear = true fully replaces cargo-make's built-in `test` task (which runs -# `cargo test`); without it our `script` would merge with the builtin `command`. clear = true -description = "Run a test set: `makers test [packages|e2e|clientless|live|all|ironwood]` (default: packages). Pass --with-zcashd for the zcashd-backed tests." -script_runner = "bash" -script = ''' -set -uo pipefail - -usage() { - echo "usage: makers test [packages|e2e|clientless|live|all|ironwood] [-- nextest args]" >&2 - echo " packages (default) packages/* tests, no live validator" >&2 - echo " e2e | clientless that single live partition" >&2 - echo " live both live partitions + combined summary" >&2 - echo " all packages then live (everything)" >&2 - echo " ironwood the ironwood tests of every set, packages then clientless then e2e" >&2 -} - -# A non-flag first arg selects the set and must be a known name; a flag first -# arg (or none) keeps the default `packages` and is forwarded to nextest. So -# `makers test --with-zcashd` runs packages with the flag, while a mistyped set -# name errors instead of silently running the default. -set="packages" -if [ "$#" -gt 0 ] && [ "${1#-}" = "$1" ]; then - case "$1" in - packages|e2e|clientless|live|all|ironwood) set="$1"; shift ;; - *) echo "makers test: unknown set '$1'" >&2; usage; exit 2 ;; - esac -fi - -case "$set" in - packages) exec makers container-test "$@" ;; - e2e) exec makers live-e2e "$@" ;; - clientless) exec makers live-clientless "$@" ;; - live) exec cargo run -q --manifest-path tools/test-runner/Cargo.toml --bin live-summary -- "$@" ;; - all) - # The summary runner's --all mode runs the packages set and both live - # partitions (each set runs even when an earlier one fails, and the exit - # code reflects any failure), so the combined table covers everything. - exec cargo run -q --manifest-path tools/test-runner/Cargo.toml --bin live-summary -- --all "$@" ;; - ironwood) - # The one definition of the ironwood selection: the two dedicated test - # binaries, plus every test whose name carries the pool's name (the - # CONTEXT.md era-naming convention keeps new ironwood tests inside this - # net). The same expression serves every engine — a selector naming a - # binary a partition lacks simply matches nothing there. The engines are - # invoked directly rather than through the `live` summary runner, which - # forwards no nextest args. Run every set even if an earlier one fails, - # as in `all`. - ironwood_filter='binary(ironwood_activation) | binary(the_pub_testnet_ironwood_boundary) | test(ironwood)' - rc=0 - makers container-test -E "$ironwood_filter" "$@" || rc=1 - makers live-clientless -E "$ironwood_filter" "$@" || rc=1 - makers live-e2e -E "$ironwood_filter" "$@" || rc=1 - exit "$rc" ;; -esac -''' - -# ------------------------------------------------------------------- - -[tasks.zcashd_test] -description = "Convenience: the whole suite with the zcashd-backed tests (`makers test all --with-zcashd`)" -# Delegates to the `test all` front door with the explicit flag — there is no -# implicit/env-var path to enable zcashd (docs/adr/0005). -script_runner = "bash" -script = 'exec makers test all --with-zcashd' - -# ------------------------------------------------------------------- - -[tasks.check-matching-zebras] -description = "Check that zebra versions in .env.testing-artifacts match what's in Cargo.toml" -extend = "base-script" +description = "Removed. Use cargo nextest run / ztest run — see docs/testing.md." script_runner = "bash" -script.main = 'source "./tools/scripts/check-matching-zebras.sh"' +script = """ +echo 'makers test was removed. Run the suites directly:' >&2 +echo ' cargo nextest run # packages/*' >&2 +echo ' cd live-tests && ztest run -p clientless -p e2e # live (k8s)' >&2 +echo 'See docs/testing.md for ztest CLI and cluster setup.' >&2 +exit 2 +""" # ------------------------------------------------------------------- -[tasks.validate-makefile-tasks] -description = "Validate all tasks work correctly with minimal execution" -dependencies = ["init-podman-volumes"] -script_runner = "@rust" -script = { file = "tools/scripts/validate-makefile-tasks.rs" } - -# ------------------------------------------------------------------- - -[tasks.validate-test-targets] -description = "Validate that nextest targets match CI workflow matrix" -script_runner = "bash" -extend = "base-script" -script.main = 'source "./tools/scripts/validate-test-targets.sh"' - -# ------------------------------------------------------------------- - -[tasks.update-test-targets] -description = "Update CI workflow matrix to match nextest targets" -script_runner = "bash" -extend = "base-script" -script.main = 'source "./tools/scripts/update-test-targets.sh"' - -# ------------------------------------------------------------------- - -[tasks.container-test-save-failures] -description = "Run container-test and save failed test names for later retry" -script_runner = "bash" -extend = "base-script" -script.main = 'source "./tools/scripts/container-test-save-failures.sh"' -script.post = "makers notify" - -# ------------------------------------------------------------------- - -[tasks.container-test-retry-failures] -description = "Rerun only failed tests from previous container-test-save-failures" +[tasks.bench] +# clear = true fully replaces cargo-make's built-in `bench` task (which runs +# `cargo bench`); without it our `script` would merge with the builtin `command`. +clear = true +description = "Run the zaino-bench harness against a running zainod (sync | concurrent | serve)" +# Release, always: a debug load generator measures itself rather than the +# server. Not a member of `default-members`, so `-p` is required to select it. script_runner = "bash" -extend = "base-script" -script.main = 'source "./tools/scripts/container-test-retry-failures.sh"' -script.post = "makers notify" +script = 'exec cargo run --release -p zaino-bench -- "$@"' # ------------------------------------------------------------------- @@ -272,23 +119,50 @@ script = 'source "./tools/scripts/verify-all.sh"' # ------------------------------------------------------------------- -# Verify the no-zcashd build compiles: `zcashd_support` is opt-in and being -# deprecated (docs/adr/0001, 0005), so the default --no-default-features build -# must stay green or it bit-rots. The reunified workspace (production + -# live-test crates) is checked in a single pass. -[tasks.check-no-zcashd-packages] -description = "cargo check --no-default-features across the whole workspace (zcashd_support off)" -command = "cargo" -args = ["check", "--workspace", "--all-targets", "--no-default-features"] - +# The workspace test archive is built with `--no-default-features`, so +# `zaino-proto`'s separate `heavy` default feature must survive that build. [tasks.check-zaino-proto-heavy] -description = "Guard: zaino-proto `heavy` must stay enabled under --no-default-features (else the no-zcashd test build silently loses zebra-state/zebra-chain/which)" +description = "Guard: zaino-proto `heavy` must stay enabled under --no-default-features (else the test build silently loses zebra-state/zebra-chain/which)" script_runner = "@rust" script = { file = "tools/scripts/check-zaino-proto-heavy.rs" } -[tasks.check-no-zcashd] -description = "Verify the no-zcashd (--no-default-features) build compiles across the workspace, and that zaino-proto `heavy` survives it" +# ------------------------------------------------------------------- + +# The experimental/alpha feature set is off by default and is therefore never +# built by the normal CI path, which is exactly how it bit-rots: two `#[cfg]` +# blocks in the finalised state's address-history writer went on referencing +# bindings that the non-gated path had renamed away, and nothing noticed +# because nothing compiled them. +# +# Gated on the `experimental_features` umbrella rather than on +# `transparent_address_history_experimental` directly, so a feature added to +# that set is covered the day it lands rather than the day someone remembers +# to extend this list. +[tasks.check-experimental-features-packages] +description = "cargo check --features experimental_features across the whole workspace" +command = "cargo" +args = [ + "check", + "--workspace", + "--all-targets", + "--features", + "zaino-state/experimental_features", +] + +# Compiling the gated code is what catches the rot; running its tests is what +# catches a behavioural regression in it. Scoped to zaino-state because that is +# the only crate the umbrella currently reaches. +[tasks.test-experimental-features] +description = "Run the zaino-state suite with the experimental/alpha features enabled" +command = "cargo" +args = ["nextest", "run", "-p", "zaino-state", "--features", "experimental_features"] + +[tasks.check-experimental-features] +description = "Verify the experimental/alpha feature set compiles workspace-wide and its tests pass" dependencies = [ - "check-zaino-proto-heavy", - "check-no-zcashd-packages", + "check-experimental-features-packages", + "test-experimental-features", ] + +# ------------------------------------------------------------------- + diff --git a/README.md b/README.md index c923fd43..ed7d2df2 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,59 @@ # Zaino + Zaino is an indexer for the Zcash blockchain implemented in Rust. -Zaino provides all necessary functionality for "light" clients (wallets and other applications that don't rely on the complete history of blockchain) and "full" clients / wallets and block explorers providing access to both the finalized chain and the non-finalized best chain and mempool held by either a Zebra or Zcashd full validator. +Zaino provides all necessary functionality for "light" clients (wallets and other applications that don't rely on the complete history of blockchain) and "full" clients / wallets and block explorers providing access to both the finalized chain and the non-finalized best chain and mempool held by a Zebra full validator. + +## Release pipeline + +Zaino ships through a gated pipeline (`dev → rc → release-ready → stable`); the +gates, tags, and blessing flow are specified in the +[release decision record](./docs/release/pipeline.md). + +| Gate | Workflow | +| ---- | -------- | +| Changeset check (`dev`-gate) | [![changeset-check](https://github.com/zingolabs/zaino/actions/workflows/changeset-check.yml/badge.svg)](https://github.com/zingolabs/zaino/actions/workflows/changeset-check.yml) | +| RC gate (nightly) | [![rc-gate](https://github.com/zingolabs/zaino/actions/workflows/rc-gate.yml/badge.svg)](https://github.com/zingolabs/zaino/actions/workflows/rc-gate.yml) | +| Blessing (release) | [![blessing](https://github.com/zingolabs/zaino/actions/workflows/blessing.yml/badge.svg)](https://github.com/zingolabs/zaino/actions/workflows/blessing.yml) | +Latest cycle tags: +[![latest RC](https://img.shields.io/github/v/tag/zingolabs/zaino?filter=cycle-*-rc.*&label=latest%20RC)](https://github.com/zingolabs/zaino/tags) +[![latest release](https://img.shields.io/github/v/tag/zingolabs/zaino?filter=cycle-*%2C!*-rc.*&label=latest%20release)](https://github.com/zingolabs/zaino/tags) + +The "latest RC" badge shows the newest `cycle--rc.` prerelease tag; "latest +release" filters those out to show the newest blessed `cycle-` tag. (Both use +shields.io tag filtering; if a shields update ever stops distinguishing the two, +fall back to a single unfiltered `github/v/tag` "latest cycle tag" badge.) ### Motivations -With the ongoing Zcashd deprecation project, there is a push to transition to a modern, Rust-based software stack for the Zcash ecosystem. By implementing Zaino in Rust, we aim to modernize the codebase, enhance performance and improve overall security. This work will build on the foundations laid down by [Librustzcash](https://github.com/zcash/librustzcash) and [Zebra](https://github.com/ZcashFoundation/zebra), helping to ensure that the Zcash infrastructure remains robust and maintainable for the future. +With the ongoing legacy-full-node deprecation project, there is a push to transition to a modern, Rust-based software stack for the Zcash ecosystem. By implementing Zaino in Rust, we aim to modernize the codebase, enhance performance and improve overall security. This work will build on the foundations laid down by [Librustzcash](https://github.com/zcash/librustzcash) and [Zebra](https://github.com/ZcashFoundation/zebra), helping to ensure that the Zcash infrastructure remains robust and maintainable for the future. -Due to current potential data leaks / security weaknesses highlighted in [revised-nym-for-zcash-network-level-privacy](https://forum.zcashcommunity.com/t/revised-nym-for-zcash-network-level-privacy/46688) and [wallet-threat-model](https://zcash.readthedocs.io/en/master/rtd_pages/wallet_threat_model.html), there is a need to use anonymous transport protocols (such as Nym or Tor) to obfuscate clients' identities from Zcash's indexing servers ([Lightwalletd](https://github.com/zcash/lightwalletd), [Zcashd](https://github.com/zcash/zcash), Zaino). As Nym has chosen Rust as their primary SDK ([Nym-SDK](https://github.com/nymtech/nym)), and Tor is currently implementing Rust support ([Arti](https://gitlab.torproject.org/tpo/core/arti)), Rust is a straightforward and well-suited choice for this software. +Due to current potential data leaks / security weaknesses highlighted in [revised-nym-for-zcash-network-level-privacy](https://forum.zcashcommunity.com/t/revised-nym-for-zcash-network-level-privacy/46688) and [wallet-threat-model](https://zcash.readthedocs.io/en/master/rtd_pages/wallet_threat_model.html), there is a need to use anonymous transport protocols (such as Nym or Tor) to obfuscate clients' identities from Zcash's indexing servers ([Lightwalletd](https://github.com/zcash/lightwalletd), [the legacy Zcash full node](https://github.com/zcash/zcash), Zaino). As Nym has chosen Rust as their primary SDK ([Nym-SDK](https://github.com/nymtech/nym)), and Tor is currently implementing Rust support ([Arti](https://gitlab.torproject.org/tpo/core/arti)), Rust is a straightforward and well-suited choice for this software. Zebra has been designed to allow direct read access to the finalized state and RPC access to the non-finalized state through its ReadStateService. Integrating directly with this service enables efficient access to chain data and allows new indices to be offered with minimal development. -Separation of validation and indexing functionality serves several purposes. First, by removing indexing functionality from the Validator (Zebra) will lead to a smaller and more maintainable codebase. Second, by moving all indexing functionality away from Zebra into Zaino will unify this paradigm and simplify Zcash's security model. Separating these concerns (consensus node and blockchain indexing) serves to create a clear trust boundary between the Indexer and Validator allowing the Indexer to take on this responsibility. Historically, this had been the case for "light" clients/wallets using [Lightwalletd](https://github.com/zcash/lightwalletd) as opposed to "full-node" client/wallets and block explorers that were directly served by the [Zcashd full node](https://github.com/zcash/zcash). - +Separation of validation and indexing functionality serves several purposes. First, by removing indexing functionality from the Validator (Zebra) will lead to a smaller and more maintainable codebase. Second, by moving all indexing functionality away from Zebra into Zaino will unify this paradigm and simplify Zcash's security model. Separating these concerns (consensus node and blockchain indexing) serves to create a clear trust boundary between the Indexer and Validator allowing the Indexer to take on this responsibility. Historically, this had been the case for "light" clients/wallets using [Lightwalletd](https://github.com/zcash/lightwalletd) as opposed to "full-node" client/wallets and block explorers that were directly served by the [the legacy Zcash full node](https://github.com/zcash/zcash). ### Goals + Our primary goal with Zaino is to serve all non-miner clients -such as wallets and block explorers- in a manner that prioritizes security and privacy while also ensuring the time efficiency critical to a stable currency. We are committed to ensuring that these clients can access all necessary blockchain data and services without exposing sensitive information or being vulnerable to attacks. By implementing robust security measures and privacy protections, Zaino will enable users to interact with the Zcash network confidently and securely. -To facilitate a smooth transition for existing users and developers, Zaino is designed (where possible) to maintain backward compatibility with Lightwalletd and Zcashd. This means that applications and services currently relying on these platforms can switch to Zaino with minimal adjustments. By providing compatible APIs and interfaces, we aim to reduce friction in adoption and ensure that the broader Zcash ecosystem can benefit from Zaino's enhancements without significant rewrites or learning curves. +To facilitate a smooth transition for existing users and developers, Zaino is designed (where possible) to maintain backward compatibility with Lightwalletd and the legacy Zcash full node. This means that applications and services currently relying on these platforms can switch to Zaino with minimal adjustments. By providing compatible APIs and interfaces, we aim to reduce friction in adoption and ensure that the broader Zcash ecosystem can benefit from Zaino's enhancements without significant rewrites or learning curves. ### Scope -Zaino will implement a comprehensive RPC API to serve all non-miner client requests effectively. This API will encompass all functionality currently in the LightWallet gRPC service ([CompactTxStreamer](https://github.com/zcash/librustzcash/blob/main/zcash_client_backend/proto/service.proto)), currently served by Lightwalletd, and a subset of the [Zcash RPCs](https://zcash.github.io/rpc/) required by wallets and block explorers, currently served by Zcashd. Zaino will unify these two RPC services and provide a single, straightforward interface for Zcash clients and service providers to access the data and services they require. +Zaino will implement a comprehensive RPC API to serve all non-miner client requests effectively. This API will encompass all functionality currently in the LightWallet gRPC service ([CompactTxStreamer](https://github.com/zcash/librustzcash/blob/main/zcash_client_backend/proto/service.proto)), currently served by Lightwalletd, and a subset of the [Zcash RPCs](https://zcash.github.io/rpc/) required by wallets and block explorers, currently served by the legacy Zcash full node. Zaino will unify these two RPC services and provide a single, straightforward interface for Zcash clients and service providers to access the data and services they require. In addition to the RPC API, Zaino will offer a client library allowing developers to integrate Zaino's functionality directly into their Rust applications. Along with the RemoteReadStateService mentioned below, this will allow both local and remote access to the data and services provided by Zaino without the overhead of using an RPC protocol, and also allows Zebra to stay insulated from directly interfacing with client software. Currently Zebra's `ReadStateService` only enables direct access to chain data (both Zebra and any process interfacing with the `ReadStateService` must be running on the same hardware). Zaino will extend this functionality, using a Hyper wrapper, to allow Zebra and Zaino (or software built using Zaino's `IndexerStateService` as its backend) to run on different hardware and should enable a much greater range of deployment strategies (eg. running validator, indexer or wallet processes on separate hardware). It should be noted that this will primarily be designed as a remote link between Zebra and Zaino and it is not intended for developers to directly interface with this service, but instead to use functionality exposed by the client library in Zaino (`IndexerStateService`). - ## Project Structure ``` packages/ Cargo workspace member crates, in dependency order zaino-status/ How a component reports whether it is working zaino-consensus/ Zcash consensus constants and protocol limits + zaino-encoding/ Versioned on-disk encoding traits and byte helpers zaino-primitives/ Domain vocabulary (thiserror only; no serde) zaino-address/ Zcash address classification zaino-source/ Driven ports: one trait per chain question @@ -45,19 +66,21 @@ packages/ Cargo workspace member crates, in dependency zaino-mempool-service/ The mempool runtime: poll loop, read handles, coherence zaino-common/ Shared utilities and configuration zaino-proto/ Protocol buffer definitions + zaino-chain-head/ Non-finalised chain head: vocabulary and ports + zaino-chain-head-service/ Non-finalised chain head: the runtime + zaino-chain-store/ Finalised state: vocabulary and ports + zaino-chain-store-zainodb/ Finalised state: the LMDB implementation zaino-state/ Chain state and indexer service library zaino-serve/ gRPC + JSON-RPC servers, and the served JSON schema zainod/ Daemon binary + zaino-bench/ Benchmark harness (sync time, connection ceiling, serve rate) -live-tests/ Live-test suite — root-workspace members, run against zcashd/zebrad +live-tests/ Live-test suite — standalone workspace, run on the ztest k8s harness e2e/ End-to-end partition (wallet client -> Zaino -> validator) clientless/ Clientless partition (Zaino services -> live validator, no client) zaino-testutils/ Shared test harness and utilities - test_binaries/ Symlinked zcashd/zebrad/zcash-cli binaries - test_environment/ Container build context - Containerfile CI/test container image definition - entrypoint.sh Container entrypoint (binary symlink setup) - test-container-permissions.sh Container permission / volume-mount tests + test_environment/ CI build-environment image context + Containerfile Rust toolchain + protoc + RocksDB + cargo-nextest (no validators) docs/ Architecture diagrams, specs, and usage guides tools/ Development tools, shell helpers, makefiles @@ -65,14 +88,12 @@ tools/ Development tools, shell helpers, makefiles makefiles/ cargo-make task definitions (lints, rocksdb, notify) .github/ CI workflows and issue templates .githooks/ Git hooks (pre-push) -.config/containers.conf Rootless podman defaults (userns, security) Cargo.toml Top-level workspace manifest Cargo.lock Resolved dependency graph (committed) Makefile.toml cargo-make task definitions rust-toolchain.toml Pinned Rust toolchain deny.toml cargo-deny policy (licenses, advisories) -.env.testing-artifacts Version pins for test container (Rust, zcashd, zebrad) Dockerfile Production container image entrypoint.sh Production container entrypoint @@ -112,23 +133,26 @@ connection. ## Running tests -The test suites run inside a **podman** container via `makers` (cargo-make): +Production-crate tests run on your host; the live suites run on a Kubernetes +cluster via [`ztest`](https://crates.io/crates/ztest_cli): ```sh -makers test # packages/* tests that need no live validator (default) -makers test live # both live partitions (clientless + e2e) + combined summary -makers test all # everything: packages then live +cargo nextest run # packages/*, no live validator +cd live-tests && ztest run -p clientless -p e2e # both live partitions ``` -zcashd-backed tests are **off by default**; add `--with-zcashd` to include them -(there is no implicit or env-var path — see docs/adr/0005). On lower-resource machines you -may hit occasional contention flakes under full parallelism — re-run, or lower -`test-threads` in the nextest config. See [docs/testing.md](./docs/testing.md) -for full instructions. +The live suites need the `ztest` CLI and a registered cluster +(`cargo install ztest_cli`, then `kind create cluster` and `ztest cluster +setup`) — see [docs/testing.md](./docs/testing.md) for the full setup. + +On lower-resource machines you may hit occasional contention flakes under full +parallelism — re-run, or lower `--test-threads`. ## Documentation + - [Use Cases](./docs/use_cases.md): Holds instructions and example use cases. - [Testing](./docs/testing.md): Holds instructions for running tests. +- [Live-test guidelines](./live-tests/CLAUDE.md): The rules for writing live tests — the live oracle, QoS tiers, parameterization. - [Live Service System Architecture](./docs/zaino_live_system_architecture.pdf): Holds the Zcash system architecture diagram for the Zaino live service. - [Library System Architecture](./docs/zaino_lib_system_architecture.pdf): Holds the Zcash system architecture diagram for the Zaino client library. - [ZainoD (Live Service) Internal Architecture](./docs/zaino_serve_architecture_v020.pdf): Holds an internal Zaino system architecture diagram. @@ -140,12 +164,13 @@ for full instructions. ### Architecture Decision Records Decisions that shape the codebase, with the reasoning that produced them. Read these before changing the structure they describe. -- [ADR-0001](./docs/adr/0001-zcashd-support-feature-gate.md) / [ADR-0005](./docs/adr/0005-zcashd-support-default-off.md): the `zcashd_support` feature gate, and why it is opt-in. -- [ADR-0002](./docs/adr/0002-live-tests-rejoin-root-workspace.md), [ADR-0003](./docs/adr/0003-live-test-taxonomy-and-two-crate-split.md), [ADR-0004](./docs/adr/0004-rename-integration-partition-to-clientless.md): the live-test suite's workspace membership, taxonomy and naming. - [ADR-0006](./docs/adr/0006-aws-lc-rs-preferred-crypto-provider.md): aws-lc-rs as the preferred rustls CryptoProvider. - [ADR-0007](./docs/adr/0007-block-persistence-is-a-row-set-boundary.md): block persistence is a row-set boundary. - [ADR-0008](./docs/adr/0008-source-ports-and-domain-primitives.md): validator access is a set of single-question ports over domain primitives. - [ADR-0009](./docs/adr/0009-served-json-schema-lives-in-zaino-serve.md): the served JSON schema lives in `zaino-serve`. +- [ADR-0010](./docs/adr/0010-mempool-subsystem-separation.md): the mempool subsystem is separated into `zaino-mempool` behind ports. +- [ADR-0011](./docs/adr/0011-chain-head-subsystem-separation.md): the non-finalised chain head is a self-synchronising subsystem. +- [ADR-0012](./docs/adr/0012-chain-store-subsystem-separation.md): the finalised state is a subsystem behind ports, and its database is one implementation of them. ### Crate usage guides Practical guidance for working *in* a crate — its scope, its invariants, and the @@ -162,13 +187,20 @@ mistakes its design is trying to prevent. - [`zaino-address`](./packages/zaino-address/usage.md): address classification, and what is not classified. - [`zaino-mempool`](./packages/zaino-mempool/usage.md): the two-layer model, the ports, and the bounds. - [`zaino-mempool-service`](./packages/zaino-mempool-service/usage.md): spawning and consuming the mempool. +- [`zaino-chain-head`](./packages/zaino-chain-head/usage.md): the chain head's ports, why reads live on the snapshot, and why there is no way to make it synchronise. +- [`zaino-chain-head-service`](./packages/zaino-chain-head-service/usage.md): the chain head runtime, its two testing styles, and the properties to keep when editing the advance path. +- [`zaino-encoding`](./packages/zaino-encoding/usage.md): the versioned record format, and why nested fields must have their version pinned. +- [`zaino-chain-store`](./packages/zaino-chain-store/usage.md): the finalised state's ports, why the chunk is the block-read primitive, and why a read past the watermark is not a miss. +- [`zaino-chain-store-zainodb`](./packages/zaino-chain-store-zainodb/usage.md): the LMDB store, its on-disk compatibility contract, and why its checksums are load-bearing. +- [`zaino-bench`](./packages/zaino-bench/usage.md): measuring sync time, connection ceiling, and serve rate on a running node. ## Security Vulnerability Disclosure + If you believe you have discovered a security issue, and it is time sensitive, please contact us online on Matrix. See our [CONTRIBUTING.md document](./CONTRIBUTING.md) for contact points. Otherwise you can send an email to: zingodisclosure@proton.me - ## License + This project is licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). See the [LICENSE](./LICENSE) file for details. diff --git a/docs/adr/0001-zcashd-support-feature-gate.md b/docs/adr/0001-zcashd-support-feature-gate.md deleted file mode 100644 index f3e1af26..00000000 --- a/docs/adr/0001-zcashd-support-feature-gate.md +++ /dev/null @@ -1,116 +0,0 @@ -# `zcashd_support` feature gate - -## Status - -accepted — the **default-on** choice is superseded by -[ADR-0005](0005-zcashd-support-default-off.md): `zcashd_support` is now -**opt-in** (`default = []`). The feature, its additive semantics, its name, and -what it gates (below) all still stand; only its default membership changed. - -## Context and decision - -We are beginning to deprecate zcashd as a supported validator backend for -Zaino. To make that path concrete and reversible, we introduce a Cargo -feature, `zcashd_support`, that is **enabled by default** and gates the -zcashd-specific code. Building with `--no-default-features` (or otherwise -dropping the feature) compiles zcashd out, giving us a "life without zcashd" -build we can exercise today, long before zcashd is actually removed. - -The feature is **additive**, in keeping with Cargo's feature model: enabling -`zcashd_support` *adds* zcashd capability; it never removes anything. We -deliberately did **not** name it `deprecate_zcashd` (or the portmanteau -`depregate_zcashd`) — a default-on feature whose name implies "remove zcashd" -inverts the usual additive semantics and would mislead. The *effort* is the -deprecation of zcashd; the *feature* that gets compiled out to achieve it is -`zcashd_support`. - -## Scope: what the feature gates - -zcashd's presence in the tree falls into three layers. The feature gates the -first two and deliberately leaves the third alone: - -1. **Test validator infrastructure** (`zaino-testutils`): `ValidatorKind::Zcashd`, - the `Zcashd` / `ZcashdConfig` imports, `ZCASHD_BIN`, `ZcashdDualFetchServices`, - `launch_zcashd_dual_fetch_services`, the `mod zcashd` test module, and the - "Cannot use state backend with zcashd" guard. Downstream test crates - (`walletless-tests`, wallet-tests) inherit the gate. - -2. **The zcashd-shaped `getpeerinfo` response** (`zaino-serve`'s - `rpc/jsonrpc/wire/peer_info.rs`): the - `GetPeerInfo::Zcashd(Vec)` variant and the `ZcashdPeerInfo` - struct. This is self-contained: the only consumers of the variant live - inside `peer_info.rs`, so gating it simply makes a no-zcashd build fall - through to the zebrad branch. - - > This lived in `zaino-fetch` when the ADR was written, where it was an - > *inbound* parse of a live zcashd node's reply. `zaino-fetch` was deleted - > by ADR-0008 and the type moved to the served-schema module, so it is now - > an *outbound* shape. The feature gates the same fields either way. - -3. **Frozen wire fields** (`zaino-proto`, populated in `zaino-state`): - `zcashd_build` / `zcashd_subversion` in the `LightdInfo` message. These are - **left untouched** — see below. - -## Why the wire fields are excluded - -`zcashd_build` (tag 13) and `zcashd_subversion` (tag 14) are prost-generated -fields of the `LightdInfo` message, defined in the lightwallet protocol -(`cash.z.wallet.sdk.rpc`, `option go_package = "lightwalletd/walletrpc"`). -They are not zcashd *capability* — they are protocol field names with a legacy -spelling, populated from zebra data today and read by every lightclient via -`GetLightdInfo`. - -Gating them with a Cargo feature is both impossible and wrong: - -- `packages/zaino-proto/src/proto/service.rs` is `@generated by prost-build`; - any hand-edit is clobbered on the next regeneration. Gating would require - forking `proto/service.proto`, diverging Zaino from the lightwalletd spec. -- Removing or renaming the fields breaks the `GetLightdInfo` wire contract for - *all* clients, independent of which validator backend is in use. - -The rename is therefore a protocol-level, client-coordinated change that -belongs upstream at `zcash/lightwalletd` (`walletrpc/service.proto`), tracked -in [zcash/lightwalletd#566](https://github.com/zcash/lightwalletd/issues/566) — -not in this feature. - -## Propagation - -`zcashd_support` is declared and forwarded through every crate in both chains, -mirroring the existing `transparent_address_history_experimental` pattern: - -- production: `zaino-serve` → `zainod` -- test: `zaino-testutils` → `clientless` / `e2e` - -> The production chain was originally `zaino-fetch` → `zaino-state` → -> `zaino-serve` → `zainod`. `zaino-fetch` has since been deleted, and the -> zcashd-shaped response types it carried now live in `zaino-serve`'s wire -> module, which gates them directly. `zaino-state` gated nothing once that -> move landed, so its declaration was removed rather than left as an empty -> pass-through: a feature that forwards to nothing still has to be threaded -> through every consumer's manifest, and reads as coverage it does not -> provide. `zaino-serve` is now the only crate where the feature gates code. - -Each crate carries it in `default = [...]`. It is **not** placed under the -`experimental_features` umbrella — that umbrella is for opt-in alpha features, -whereas `zcashd_support` is default-on and stable-but-deprecating (opposite -polarity). - -## Consequences - -- The no-zcashd build is enforced by `makers check-no-zcashd`, which runs - `cargo check --no-default-features --all-targets` across all three workspaces - (production `packages/`, `integration-tests/`, `integration-tests/wallet-tests/`). - A `no-zcashd-build` job in `.github/workflows/ci.yml` invokes it. Without this - the disabled state would bit-rot and the deprecation path would become fiction. -- Because a default-on feature only drops out when every consumer pulls the - carrier crates with `default-features = false`, the `[workspace.dependencies]` - tables of all three workspaces set `default-features = false` on the zaino - crates that carry `zcashd_support`; each consumer re-enables it by forwarding - the feature in its own `default`. -- Per-validator behaviour that was matched inline (the default activation-height - selection, duplicated in `zaino-testutils` and `wallet-tests`) is now a single - `ValidatorKind::default_activation_heights` method, so the `Zcashd` match arm - is gated in exactly one place and the enum stays exhaustive when the variant - is compiled out. -- The stale branches `depgregate_zcashd` and `zcashd_depgregate` (typos of the - effort name) can be deleted; the live branch is `zcashd_support`. diff --git a/docs/adr/0003-live-test-taxonomy-and-two-crate-split.md b/docs/adr/0003-live-test-taxonomy-and-two-crate-split.md index c53c7731..c9c667f4 100644 --- a/docs/adr/0003-live-test-taxonomy-and-two-crate-split.md +++ b/docs/adr/0003-live-test-taxonomy-and-two-crate-split.md @@ -18,7 +18,7 @@ We adopt this taxonomy, captured as ubiquitous language in the suite's `CONTEXT.md`: - **`live`** — the umbrella (directory `live-tests/`). The defining property of - the suite is that it requires a **live validator** process (Zebra or zcashd), + the suite is that it requires a **live validator** process (Zebra or the legacy full node), and optionally a live wallet client. That property — not "it has a `tests/` folder" — is what separates it from the inline unit tests `container-test` runs, and is the reason it is excluded from the default flow. diff --git a/docs/adr/0005-zcashd-support-default-off.md b/docs/adr/0005-zcashd-support-default-off.md deleted file mode 100644 index 3b96c162..00000000 --- a/docs/adr/0005-zcashd-support-default-off.md +++ /dev/null @@ -1,48 +0,0 @@ -# `zcashd_support` is opt-in, not a default feature - -## Status - -accepted (supersedes the default-on decision in ADR-0001) - -## Context and decision - -ADR-0001 made `zcashd_support` a **default-on** additive feature, with the test -harness opting out via `--no-default-features`. That left zcashd enabled by -default on every path *outside* the harness — bare `cargo build` / `cargo -nextest run`, and any consumer of the crates — and it required a fragile -`default-features = false` annotation on every internal `zaino-*` path -dependency to drop the feature end-to-end. The zcashd-backed tests could also be -switched on implicitly through an ambient `CONTAINER_TEST_WITH_ZCASHD=1` env var. - -We make `zcashd_support` **opt-in**: `default = []` in all crates that defined -it, the feature definitions (`zcashd_support = [...]`) unchanged. Nothing enables -zcashd unless explicitly asked. Concretely: - -- Bare `cargo build` / `cargo nextest run` now compile zcashd out by default. -- The internal `zaino-*` path deps no longer carry `default-features = false` - (their default set is already empty) — the annotation and its rationale are - removed. -- The test harness enables zcashd only via the explicit `--with-zcashd` flag, - which adds `--features zcashd_support`. The `CONTAINER_TEST_WITH_ZCASHD` env - var is **retired**; the flag is forwarded through the task chain instead, so - there is no implicit/ambient enable. - -## Considered options - -- **Keep ADR-0001's default-on.** Rejected: zcashd-on-by-default is exactly the - implicit behaviour we want gone during deprecation, and the consumer-facing - default should reflect the zebrad-only future. -- **Flip the default but keep the env var.** Rejected: an exported - `CONTAINER_TEST_WITH_ZCASHD=1` is a sticky implicit enable; collapsing to a - single explicit flag is simpler and matches the "never implicit" goal. - -## Consequences - -- `--no-default-features` is now a no-op for the flipped crates (their default - is empty); CI and the target-list scripts keep passing it (still correct: it - also drops other crates' defaults, e.g. `zaino-proto`'s `heavy`). -- Enabling zcashd is `--features zcashd_support` (cargo accepts it at the - virtual workspace root and with `-p`), surfaced as `--with-zcashd` / - `makers zcashd_test`. -- ADR-0001's feature, semantics, name, and gated scope still stand; only its - default membership changed. diff --git a/docs/adr/0008-source-ports-and-domain-primitives.md b/docs/adr/0008-source-ports-and-domain-primitives.md index bb33731c..a5b83f66 100644 --- a/docs/adr/0008-source-ports-and-domain-primitives.md +++ b/docs/adr/0008-source-ports-and-domain-primitives.md @@ -13,7 +13,7 @@ port was declared in the transport's vocabulary rather than Zaino's, and three consequences followed. **Errors were unclassifiable.** `BlockchainSourceError` had two variants, both -"Unrecoverable". `zaino-serve` recovered zcashd RPC error codes by +"Unrecoverable". `zaino-serve` recovered the legacy full node RPC error codes by downcast-walking `source()` chains for a `zaino-fetch` connector type, and the ChainIndex sync loop hand-rolled a retry ladder that counted consecutive failures, because nothing in the type told retryable from fatal. Worse, the diff --git a/docs/adr/0009-served-json-schema-lives-in-zaino-serve.md b/docs/adr/0009-served-json-schema-lives-in-zaino-serve.md index baa6ed8c..ea3e04cc 100644 --- a/docs/adr/0009-served-json-schema-lives-in-zaino-serve.md +++ b/docs/adr/0009-served-json-schema-lives-in-zaino-serve.md @@ -20,7 +20,7 @@ is what pinned `zaino-fetch` in the workspace after `zaino-rpc` and Serving one type in both directions is not a tidiness problem. It means the shape Zaino *accepts* from a validator and the shape Zaino *emits* to a client cannot diverge, even where the interfaces genuinely differ — and they do -differ. zebrad spells a value pool `lockbox` where zcashd spells it `deferred`; +differ. zebrad spells a value pool `lockbox` where the legacy full node spells it `deferred`; `z_gettreestate`'s `finalRoot` is display-order while `z_getsubtreesbyindex`'s subtree roots are not. A single struct forces one answer to questions that have two. @@ -28,7 +28,7 @@ two. We decide: 1. **The served JSON schema is `zaino-serve`'s**, in - `src/rpc/jsonrpc/wire/` — serde structs carrying zcashd's exact field names, + `src/rpc/jsonrpc/wire/` — serde structs carrying the legacy full node's exact field names, one module per response family, next to the `#[rpc(server)]` trait that is their only consumer. @@ -87,14 +87,14 @@ different contracts. - **Generate the served schema from a specification** — deferred, not rejected. It is the right long-term answer for a wire contract, and `zaino-proto` already works this way for gRPC. There is no machine-readable specification of - the zcashd JSON-RPC surface to generate from, and writing one is a larger + the legacy full-node JSON-RPC surface to generate from, and writing one is a larger piece of work than this rewire. ## Consequences - **A defect closed rather than carried.** `zaino-serve`'s error recovery downcast-walked for `zaino_fetch`'s `RpcError`, which the new stack never - constructs — so zcashd error-code recovery was **silently inert**: every code + constructs — so the legacy full node error-code recovery was **silently inert**: every code reached the client as a generic internal error. It now matches `zaino_source::FetchError`'s `FailureMode::RpcError(i64)` (the validator's code) and `zaino_state::LegacyRpcError` (Zaino's own), and is tested @@ -106,7 +106,7 @@ different contracts. reversal happened somewhere in a shared type and neither call site said so. - **Both spellings of a value pool are accepted.** `lockbox` (zebrad) and - `deferred` (zcashd) map to the same domain pool, so the served answer does not + `deferred` (the legacy full node) map to the same domain pool, so the served answer does not depend on which validator is behind the adapter. This was a live-suite failure, not a hypothetical. diff --git a/docs/adr/0010-mempool-subsystem-separation.md b/docs/adr/0010-mempool-subsystem-separation.md index 58575ebc..09b0e73a 100644 --- a/docs/adr/0010-mempool-subsystem-separation.md +++ b/docs/adr/0010-mempool-subsystem-separation.md @@ -118,7 +118,7 @@ race this rework closed. See `packages/zaino-mempool/docs/mempool_lifecycle.md`. ### Protocol-correct internally, wire shape at the boundary Entries hold the full unmined transaction and mirror what the validator stores per -unconfirmed transaction (Zebra `VerifiedUnminedTx` / zcashd `nHeight`): the +unconfirmed transaction (Zebra `VerifiedUnminedTx` / the legacy full node `nHeight`): the tip-at-entry height, sourced from the validator, not derived. The entry carries **no** parsed or wire form — not a `Transaction`, not a compact diff --git a/docs/adr/0011-chain-head-subsystem-separation.md b/docs/adr/0011-chain-head-subsystem-separation.md new file mode 100644 index 00000000..8d4ffb81 --- /dev/null +++ b/docs/adr/0011-chain-head-subsystem-separation.md @@ -0,0 +1,169 @@ +# The non-finalised chain head is a self-synchronising subsystem + +## Status + +accepted + +## Context and decision + +The non-finalised state was a module inside `zaino-state` +(`chain_index/non_finalised_state.rs`, 986 lines). It held the recent block +graph and the reorg handling, which is where it belonged. Everything about +*how* it was reached was wrong, and four consequences followed. + +**It could not advance without the finalised state.** It read +`FinalisedState::db_height()` to pick its anchor, and `DbReader` to resolve +blocks below its window. A deployment with no database — the ephemeral mode +added for exactly this reason — took the fallback arm of every one of those +reads. The dependency existed in the types without ever being needed by the +logic. + +**It could not advance without being told to.** It had no task. `ChainIndex`'s +sync worker called `sync(finalized_db, chain_height)`, so the two layers +advanced in lockstep at whichever rate suited the slower one, and the freshness +of the chain tip was decided by the database's write throughput. Any consumer +holding the module could also drive it, so "how far along is the head" was a +question about the caller rather than about the chain. + +**Publication was not atomic.** `sync` called `update()` every `depth` blocks +mid-catch-up and re-anchored by storing into the shared cell directly, so a +reader could observe a partially-filled window or a half-applied reorg. The +compare-and-swap this was built on defended against a second writer that did +not exist. + +**Its boundary was `IndexedBlock`**, the persistence type. A representation +chosen for what a database row needs was the currency of an in-memory graph +that has no database. + +We decide: + +1. **The chain head is its own subsystem, in two crates.** `zaino-chain-head` + is vocabulary and ports — no runtime, no data structures. + `zaino-chain-head-service` is the runtime. This is the same + `` / `-service` split the rest of Zaino's layering uses, and + it is what lets a consumer name what the chain head answers without + depending on the machinery that answers it. + +2. **It owns one writer task and synchronises itself.** There is no `sync`, + `sync_to_height`, `reconcile` or `advance` on any public port, at any + visibility. A consumer that could drive it could sequence it against + something else, which reintroduces precisely the coupling this separation + removes. The read handle, `ChainHeadSubscriber`, cannot start, stop or step + it — that is a property of the types, not a convention. + +3. **It never reads the finalised state.** The anchor is `tip - max_depth`, + computed from the validator's tip. This is the arm the old code already took + whenever the database lagged; removing the other arm removed the dependency + without changing the behaviour anyone was getting. + +4. **Snapshots are values, published atomically.** A candidate is built to + completion and installed with one store. Every published snapshot described + the chain at a single instant, so a reader capturing one and asking it + several questions gets consistent answers, and can hold it across a reorg. + +5. **`ChainHeadSnapshot` is a trait**, not a struct. The graph's representation + — today `MapBackedSnapshot`, cloned per publish — is the runtime's business. + Replacing it with persistent structures that share unchanged subtrees between + publishes must be invisible to consumers, and with the type abstract it is. + +6. **Reads live on the snapshot; the handle produces snapshots.** The handle + has `current()` and `subscribe_updates()` and nothing else. Restating each + query on the handle would define every capability twice and would let a + caller ask two questions of two different views by accident. This matches the + layering the chain view above it uses, so snapshot-pinned reads are + structural at both levels. + +7. **The driven port names only what the chain head asks.** + `ChainHeadBlockSource` is a bound alias over five `zaino-source` ports with a + blanket impl: `GetChainTip`, `GetBlock`, `GetBlockByHash`, + `GetCommitmentTreeRoots`, `SubscribeBlocks`. Not `GetChainTips` — see below. + +## `getchaintips` is not a question the chain head asks + +The port originally carried `GetChainTips`, on the assumption that competing +branches are discovered by asking the validator to enumerate tips. The original +implementation never did this. It reached non-canonical blocks two ways: + +- **Reorg fallout.** When the block after the tip has a parent the head does not + hold, the reorg walk fetches ancestors by hash until it reaches a retained + block. Separately, extending the chain overwrites the height index at each + height while leaving the blocks themselves in place, so yesterday's best chain + becomes today's competing branch and stays for the width of the window. +- **A block-carrying listener that was dead code.** Every production source + returned `Ok(None)`, so the handler early-returned and the fifty-odd lines + behind it were unreachable. + +So the chain head only ever knew branches it personally lived through, and it +still does. Discovering a fork that existed in parallel but was never its own +best chain is a **new capability**, not part of this separation. Keeping +`GetChainTips` in the bound would oblige every source to answer a question +nothing poses, and would misdescribe what the subsystem needs. + +Recorded for whoever builds that capability: **`zebra-rpc` does not implement +`getchaintips`** — zero occurrences in the crate. It will need a ReadState-backed +answer before it can work against zebrad at all. That cost a full live-suite run +to discover. + +## Freeze handoff + +A block that falls below the consensus seam — `tip - max_depth`, past which no +reorg can reach it — is emitted on a broadcast channel as a whole +`ChainHeadBlock`, tree roots included, so a chain store can ingest it without +fetching it again. This is the one capability here with no equivalent in the +original, added deliberately because the alternative is designing the port later +against a consumer that already exists and constrains it. + +The stream is **best-effort by design**, and this is the part to understand +before relying on it. It is a `broadcast`: the chain head follows the tip and +must never stall on a slow consumer, so a consumer that falls behind gets +`RecvError::Lagged(n)` and learns exactly how many it missed. A chain head +re-anchoring after an outage moves its floor discontinuously and never emits the +skipped blocks at all. Neither is an error condition. The store's own build from +source is the authority; this only spares it the fetch in steady state. + +## Consequences + +**Startup is fallible where it was not.** A chain head has no persistent state +and no second data source, so one that cannot reach its validator has nothing to +offer. It anchors before its constructor returns or fails, and `ChainIndex::new` +fails with it — where the old code retried in the background indefinitely. +Transient failures are still absorbed by the retry ladder; sustained +unavailability at startup is now reported instead of hidden. In exchange, +`current()` is total for the rest of the process's life, and the "still +syncing" case disappears from every read path. + +**The two layers now advance independently.** The finalised state's sync worker +no longer drives the head, so a slow database no longer holds the tip back. +Under ephemeral operation the head serves tip queries immediately while the +database is still building, which the live suite covers. + +**`zaino-state` keeps a temporary conversion.** `chain_index/chain_head.rs` +converts `ChainHeadBlock` back into `IndexedBlock`, because the query paths in +that crate still read the persistence type. It exists so the subsystem could be +extracted without rewriting every query path in the same change, and it +disappears when `ChainIndex` becomes the chain view layer. Its round-trip test +compares it against the finalised state's own conversion field by field, so the +two cannot drift while both exist. + +**The retained graph answers `getchaintips`.** With no validator fallback — the +same answers the old derivation produced from the same graph. + +**The chain head is now the mempool's notion of "which chain".** The mempool +subsystem's coherence layer freezes and thaws against a non-finalised-state +epoch (ADR-0010); with the head extracted, that epoch is published by the chain +head, read +through the same subscriber the rest of `ChainIndex` serves snapshots from. +Two consequences worth naming: + +- The epoch is read *from a snapshot*, not from the handle. A coherence check + asks whether a transaction set matches the chain the caller is being served, + and the caller is being served a captured view; comparing against the handle + would compare against whatever has been published since. +- The sync loop no longer relays a publication signal. It does not drive the + head and so does not know when the head publishes; the head's own epoch feed + is both more accurate and one fewer thing to keep in step. `zaino-state` + bridges that feed to the port's unit-typed wake, which is the one piece of + glue this arrangement costs. + +Neither subsystem names the other's types: the translation lives in +`chain_index/chain_head.rs`, which is the only place that knows both. diff --git a/docs/adr/0012-chain-store-subsystem-separation.md b/docs/adr/0012-chain-store-subsystem-separation.md new file mode 100644 index 00000000..1d98e199 --- /dev/null +++ b/docs/adr/0012-chain-store-subsystem-separation.md @@ -0,0 +1,177 @@ +# The finalised state is a subsystem behind ports, and its database is one implementation of them + +## Status + +accepted + +## Context and decision + +The finalised state was a directory inside `zaino-state` +(`chain_index/finalised_state/**`, ~16.8k lines, plus ~4.2k lines of on-disk +types under `chain_index/types/db/**`). What it did — hold the blocks below the +reorg seam and the indexes built over them — belonged there. Four things about +*how* it was reached did not. + +**There was no way to name the finalised state without naming its database.** +Every consumer bound on `DbReader`, an LMDB-shaped trait whose methods return +LMDB-shaped types. Substituting a different store meant replacing a crate that +everything else spelled out by name, so "a second backend" was not a matter of +satisfying an interface; there was no interface. + +**Its read surface was ~40 methods, of which 12 had a caller outside the +directory.** The rest were internal assembly helpers that had become public +because the module boundary was a directory rather than a crate. A reviewer +could not tell the contract from the scaffolding. + +**`tonic::Status` was baked into the storage layer.** `CompactBlockStream` was +`ChannelStream` with a gRPC status as its error type, so an LMDB +cursor desync was phrased as a transport-level error some two thousand lines +below the transport. + +**A range of blocks was N sequential read transactions.** `get_block_range` +opened one `begin_ro_txn` per height and sent one item per block through a +bounded channel, and the `StoredBlock` path carried a standing +`TODO: Add separate range fetch method!`. Reading a large range paid per-block +transaction setup and ~1000× more channel sends than the work required. + +We decide: + +1. **The finalised state is its own subsystem, split domain from adapter.** + `zaino-chain-store` is vocabulary and ports — no runtime, no storage, no + `zebra-chain`, no `tonic`. `zaino-chain-store-zainodb` is the LMDB + implementation and the on-disk types it is built from. This is the same + `` / `` split ADR-0008 established for validator access and + ADR-0010 for the chain head. + +2. **The on-disk types are adapter-private, and the orphan rule enforces it.** + `Persistent*` shapes live with their encoders and their golden vectors in the + backend crate. They are lossy by design — a stored transparent output holds a + 20-byte address key, not the locking script — and that lossiness is why the + domain has its own `StoredTxOut` and `StoredAddress` rather than reusing + `TransparentOutput` and `TransparentAddress`. + +3. **Capability is a set of traits, plus a runtime bitset where it must be.** + Only `ChainStoreReader` is universal. Compact blocks, transaction positions, + spent outputs, the txout set and address history are each their own trait, so + a consumer's bound names exactly what it uses. `StoreCapabilities` remains a + runtime value because a store on an older schema genuinely lacks an index + until it has migrated — that is a fact about a database, not about a type. + +4. **The chunk is the block-read primitive.** `blocks_chunk` / `compact_chunk` + return a `Vec` from one read transaction; `blocks_stream` / + `compact_stream` return an opaque `impl Stream` of chunks. There is + deliberately no single-block method: a single block is a chunk of one, and + naming a point read would invite the per-height transaction loop back. The + error type is `ChainStoreError`; `zaino-serve` maps it to a status. + +5. **The pool filter is pushed into the compact read, not applied after it.** + `CompactBlockRead` is separate from `StoredBlockRead` for this reason alone: + the filter selects which cursors open and which row families decode, so + deriving compact blocks from stored ones would make a sapling-only wallet pay + to decode orchard, ironwood and the commitment-tree rows on every block. + +6. **The store owns its source.** `ChainStoreIngest::build_to` takes a target + height and no source argument, so a consumer cannot repoint a running store + mid-flight. There is no `sync(source, height)` on any port. + +7. **The UTXO-set commitment is defined in the domain crate, and computed + there.** `hash_serialized`, and the `bytes_serialized` beside it, are numbers + two deployments can compare. Three things therefore belong to no single + backend: the domain tag `b"ZcashTxOutSet___"`, the canonical entry's field + order and widths, and the 65-byte entry length that `bytes_serialized` is a + multiple of. All three live in `zaino-chain-store::txout_set`, along with the + per-output fold that applies them. + + This is not a disk format. The canonical entry is a hash preimage — built, + hashed, dropped, never written — and the crate deliberately defines no + storage encoding for the accumulator. `zaino-chain-store-zainodb` keeps its + own persisted row and its own `ZainoVersionedSerde` for it, and delegates the + arithmetic. What the contract *does* require of any adapter is the ability to + produce five values per unspent output: txid, output index, value, a 20-byte + address hash and a one-byte script tag. That last pair is a real constraint + worth naming — the commitment bakes in Zaino's lossy transparent encoding, so + an adapter storing whole `script_pubkey`s must still reduce each to that pair + to compute it. + + The reason this is a decision and not an implementation detail: two adapters + that disagree here do **not** fail. Each is internally consistent, no read + notices, and nothing errors — they simply report different numbers for the + same chain. There is no runtime check to add, so the only available + enforcement is a single definition, which is what this is. The digest and the + fold previously existed in two copies that agreed only because one was + written from the other. + +8. **The ephemeral backend moves verbatim, both roles intact.** It is the + passthrough for deployments with no database *and* the read shim + (`init_or_take_ephemeral`) that answers while a long build or migration is in + progress. Losing the second role means a store 100k blocks behind returns + `None` for every read. + +## What deliberately did not change + +The integrity machinery moved byte-for-byte, because it is load-bearing rather +than belt-and-braces. The environment is opened `MDB_NOSYNC`, and the documented +consequence is that a hard eviction on networked or overlay storage can leave +torn pages. Per-row BLAKE2b-256 checksums over `encoded_key ‖ encoded_value` are +what turn that into `"checksum mismatch"` with a hex dump instead of a wrong +answer, and the **key binding** is what defeats a relocation or splice. The +version-searching `verify` is what makes mixed-version rows in one table safe. +The background validator, the startup spent-table sweep with byte-level +diagnostics, and the migration completion gate — advance the version durably +*before* deleting the progress key — all move unchanged. + +Because it moved unchanged, it had to be proved unchanged. Golden hex vectors +for every `ZainoVersionedSerde` implementation were checked in **before** the +first line moved, and pass identically after. A build-time assertion that +`blake2b(DB_SCHEMA_V1_TEXT)` equals the `DB_SCHEMA_V1_HASH` literal closes the +one gap in that scheme, where the drift detector could itself drift. + +## Two disagreements between indexes, now stated rather than implied + +The address index and the txout-set accumulator do not agree about which +outputs exist, and this is intentional. `build_transaction_output_histories` +keys **every** output, storing a non-standard script under its first ≤20 bytes +tagged `0xFF`; the accumulator excludes those via `is_unspendable`, mirroring +zcashd's `IsUnspendable()`. Each port now states which semantics it exposes. +Recorded as a live hazard: the `len == 21` arm of that encoding reinterprets any +21-byte script as `[type_byte ‖ hash]` and can land under a P2PKH or P2SH key. +Fixing it is an on-disk semantic change and needs a migration. + +The store also **cannot answer maturity questions**. Coinbase is special-cased +on inputs — null prevouts are filtered — and there is no coinbase flag on any +stored output. No port pretends otherwise. + +## Consequences + +**A second store is now a matter of implementing traits.** Two mechanical +obstacles remain in the adapter and are recorded here for whoever takes them on: +`DbWrite::write_blocks_to_height` is generic per method and the backend surface +is RPITIT throughout, so neither is `dyn`-safe; and `FinalisedSource` is a +closed `V1 | Ephemeral` enum matched exhaustively in three places. That enum is +the real second-adapter seam, and it is not a trait yet. + +**`zaino-state` reads the store through a bridge, not through LMDB.** +`chain_index/chain_store.rs` carries `WithChainStoreSource` beside the chain +head's equivalent. `ChainIndex` still exists and still passes its suites, which +is what makes this change revertable. + +**`StoreCapabilities` is interim, and says so in its own documentation.** It is +the finalised state's internal routing model surfaced so `ChainIndex` keeps +working. It is storage-shaped — one bit per backend trait — where the layer +above needs a domain-shaped answer to "what is answerable to height H". The +chain view replaces its consumer; nothing new should be built on it. + +**One tonic remnant survives in the adapter.** The domain ports are +`tonic`-free, but `ChannelStream` and the six serving stream aliases +(`RawTransactionStream`, `CompactBlockStream`, …) that `zaino-state` re-exports +still carry `tonic::Status`, and so `zaino-chain-store-zainodb` still depends on +`tonic`. Those aliases belong to the serving layer and leave with it when the +chain view lands; the layering violation is confined to a type alias rather than +threaded through the read paths. + +**The finalised-state test suite moved with the code it tests.** Unit, +migration, v1 and ephemeral suites run in the backend crate against a local fake +validator. The mockchain and proptest suites stay in `zaino-state`, driven +through the new surface, and read the vector chain from the backend crate's +`testing` feature so that both sides compare against one oracle rather than two +copies of one. diff --git a/docs/decision_records/release/periodic.md b/docs/adr/0015-periodic-release-flow.md similarity index 99% rename from docs/decision_records/release/periodic.md rename to docs/adr/0015-periodic-release-flow.md index ad03f0e1..d3f6850b 100644 --- a/docs/decision_records/release/periodic.md +++ b/docs/adr/0015-periodic-release-flow.md @@ -1,3 +1,10 @@ +> **Status: superseded** by +> [ADR-0016](0016-changeset-derived-release-pipeline.md), specified in +> [docs/release/pipeline.md](../release/pipeline.md). This is the record of +> the periodic release flow: the first resolution of zingolabs ADR 003's +> deferred cadence and RC-validation items. Kept for the decision trail; do +> not update. + # Zaino Release Flow Design ## Context diff --git a/docs/adr/0016-changeset-derived-release-pipeline.md b/docs/adr/0016-changeset-derived-release-pipeline.md new file mode 100644 index 00000000..ebb3c7cb --- /dev/null +++ b/docs/adr/0016-changeset-derived-release-pipeline.md @@ -0,0 +1,41 @@ +# Releases derive from changesets through a four-branch gated pipeline + +## Status + +accepted — supersedes [ADR-0015](0015-periodic-release-flow.md) (and, through +it, zingolabs ADR 003). The machinery lands inert behind the repo variable +`RELMAN_PIPELINE_ACTIVE` and activates at a deliberate cutover. + +## Context and decision + +Releases were manual: hand-bumped versions across the workspace, hand-written +changelogs, hand-cut tags, and a dependency-ordered manual publish of the 17 +governed crates. Each step could drift from the others with nothing to detect +it. The periodic flow (ADR-0015) fixed cadence and RC validation but kept +version-named `rc/*` branches and manual version derivation. + +Decision: a contributor lands each PR with a **changeset** — a TOML file +naming the crates changed and the semver kind of each change. CI derives +everything else: per-crate version bumps, changelog entries, tags, and the +publish plan. No human writes a version number. Code advances through four +fixed, version-agnostic branches — `dev → rc → release-ready → stable` — with +a named quality gate at each admission. The one human release action is the +**blessing**: the merge to `stable`. Derivation is owned by the `relman` CLI +(deterministic, no network or ref mutation); workflow YAML is a thin shell. +Released changesets are consumed by marking, never deleted, so `.changesets/` +is a provenance ledger and stale entries are inert. + +## Consequences + +- Contributors must write a changeset per governed change (advisory until + `RELMAN_ENFORCE_CHANGESETS` is set). +- Version-named `rc/*` branches and the legacy auto-tag workflows are gone. +- Release credentials concentrate in CI. The inventory, exposure posture, and + release-advance paths are recorded in + [docs/release/implementation.md](../release/implementation.md) § Trust + model; replacing the stored crates.io token with Trusted Publishing (OIDC) + is an open item there. +- The full specifications live in `docs/release/`: + [pipeline.md](../release/pipeline.md) (policy), + [changeset-format.md](../release/changeset-format.md) (contributor + contract), [implementation.md](../release/implementation.md) (architecture). diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..4d4abe53 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,27 @@ +# Architecture Decision Records + +The repo's single decision ledger. "Architecture" reads broadly: any decision +that shapes how this repo is built, tested, or released belongs here — +system structure, process, and infrastructure alike. + +## The ledger rule + +Records are append-only. Supersede, never delete or rewrite: a reversed +decision gets a new record marked "supersedes N", and the old record gets a +superseded-status header naming its successor (see ADR-0015). A record +documents the decision as made — it does not track the code, so "the code +changed" is never a reason to remove one. + +## Records vs specifications + +A record states one decision: context, decision, consequences. Keep it short +(target ~40 lines). Full governed behaviour belongs in a specification +elsewhere in `docs/` (e.g. `docs/release/pipeline.md`), revised in place and +referenced from its record. When a decision only exists inside a long +document, extract it into a record here. + +## Numbering + +Take the next free number when proposing. Concurrent proposals may collide; +renumber at merge, not before. A gap in the sequence means a proposal was +abandoned or renumbered, nothing more. diff --git a/docs/docker.md b/docs/docker.md index ef59f278..ec4d0e8c 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -132,11 +132,3 @@ The image includes a health check: ```bash docker inspect --format='{{.State.Health.Status}}' ``` - -## Local Testing - -Permission handling can be tested locally: - -```bash -./live-tests/test_environment/test-container-permissions.sh zaino:latest -``` diff --git a/docs/example_configs/zainod-bench-mainnet-ephemeral.toml b/docs/example_configs/zainod-bench-mainnet-ephemeral.toml new file mode 100644 index 00000000..8fa0c7eb --- /dev/null +++ b/docs/example_configs/zainod-bench-mainnet-ephemeral.toml @@ -0,0 +1,46 @@ +# Zaino mainnet benchmark configuration — ephemeral finalised state +# +# Identical to `zainod-bench-mainnet.toml` except for +# `ephemeral_finalised_state`. In ephemeral mode Zaino keeps no finalised-state +# database of its own and serves finalised reads by passthrough to the +# validator, so `serve` and `concurrent` exercise a genuinely different path. +# `docs/perf.md` reports both. +# +# There is nothing to sync in this mode, so `zaino-bench sync` does not apply: +# the `zaino.sync.*` gauges it reads are never emitted, and it will sit waiting +# for a metric that never arrives. Sync is a persistent-mode measurement. +# +# cargo build --release -p zainod --features prometheus +# zainod start --config docs/example_configs/zainod-bench-mainnet-ephemeral.toml + +backend = 'direct' +zebra_db_path = '' +network = 'Mainnet' + +# The ephemeral half of the pair — the one line that differs. +ephemeral_finalised_state = true + +metrics_endpoint = '127.0.0.1:9998' + +[grpc_settings] +listen_address = '127.0.0.1:8137' + +[validator_settings] +validator_grpc_listen_address = '127.0.0.1:18230' +validator_jsonrpc_listen_address = '127.0.0.1:18232' +validator_user = 'xxxxxx' +validator_password = 'xxxxxx' + +[service] +timeout = 30 +channel_size = 32 + +[storage.cache] +capacity = 10000 +shard_power = 4 + +# Still required by the config schema, and still the cache location, even though +# no finalised-state database is written here in ephemeral mode. +[storage.database] +path = '' +size = 384 diff --git a/docs/example_configs/zainod-bench-mainnet.toml b/docs/example_configs/zainod-bench-mainnet.toml new file mode 100644 index 00000000..6d8de3af --- /dev/null +++ b/docs/example_configs/zainod-bench-mainnet.toml @@ -0,0 +1,73 @@ +# Zaino mainnet benchmark configuration — persistent finalised state +# +# The node config behind the numbers in `docs/perf.md`. Paths and ports match +# the measuring host; change them for yours, but keep the two choices that make +# the numbers mean what they claim: +# +# backend = 'direct' the fastest path Zaino has +# ephemeral_finalised_state = false Zaino answers from its own index +# +# Pair it with `zainod-bench-mainnet-ephemeral.toml`, which flips the second. +# `docs/perf.md` reports the serve and concurrency numbers under both. +# +# zainod must be built with the `prometheus` feature for `metrics_endpoint` to +# bind — `zaino-bench sync` reads sync progress from there: +# +# cargo build --release -p zainod --features prometheus +# zainod start --config docs/example_configs/zainod-bench-mainnet.toml +# +# See `packages/zaino-bench/usage.md` for the full run procedure. + +# "direct" reads Zebra's ReadStateService in-process; requires zainod and zebrad +# on the same host. ("state" is the legacy spelling of the same value.) The +# alternative, "rpc" ("fetch"), goes over JSON-RPC — a different question. +backend = 'direct' + +# Zebra's `[state] cache_dir`, verbatim. +zebra_db_path = '' + +network = 'Mainnet' + +# The persistent half of the pair: Zaino keeps its own finalised-state database +# and serves finalised reads from it. +ephemeral_finalised_state = false + +# Where `zaino-bench sync` scrapes progress from. Requires --features prometheus; +# the field parses either way, it just has no effect without it. +metrics_endpoint = '127.0.0.1:9998' + +[grpc_settings] +listen_address = '127.0.0.1:8137' + +[validator_settings] +# Must match zebrad.toml's `[rpc]`, and the two must differ from each other +# (zainod rejects a config where they collide): +# +# [rpc] +# listen_addr = '127.0.0.1:18232' -> validator_jsonrpc_listen_address +# indexer_listen_addr = '127.0.0.1:18230' -> validator_grpc_listen_address +# +# The gRPC address is required by the Direct backend; zainod refuses to start on +# Direct without it. +validator_grpc_listen_address = '127.0.0.1:18230' +validator_jsonrpc_listen_address = '127.0.0.1:18232' +validator_user = 'xxxxxx' +validator_password = 'xxxxxx' + +# Left at defaults for the published numbers. `channel_size` in particular +# bounds concurrency, so raising it changes the answer to "how many connections +# can you support" — if you tune it, say so in docs/perf.md alongside the number. +[service] +timeout = 30 +channel_size = 32 + +[storage.cache] +capacity = 10000 +shard_power = 4 + +[storage.database] +path = '' +# GiB. Measured: a full mainnet index is ~76 GiB at height 3.45M. This is the map +# size (an upper bound, not a reservation), so leave headroom above that and +# the volume needs the free space to match. +size = 384 diff --git a/docs/example_configs/zainod.toml b/docs/example_configs/zainod.toml index 27e9bd7f..be0ef8af 100644 --- a/docs/example_configs/zainod.toml +++ b/docs/example_configs/zainod.toml @@ -11,7 +11,7 @@ # https://github.com/zingolabs/zaino backend = 'fetch' -zebra_db_path = '/home/cupress/.cache/zebra' +zebra_db_path = '' network = 'Testnet' # Run the finalised state in ephemeral/stateless mode. When true, Zaino opens no @@ -36,7 +36,7 @@ capacity = 10000 shard_power = 4 [storage.database] -path = '/home/cupress/.cache/zaino' +path = '' size = 128 # Heap budget (in GiB) for the bulk-sync write batch (buffered blocks only). Lower this on # memory-constrained hosts; a larger value does not speed up a small host, it just risks the diff --git a/docs/example_configs/zebrad_config_3.1.0.toml b/docs/example_configs/zebrad_config_3.1.0.toml index 85580b4c..036b3b34 100644 --- a/docs/example_configs/zebrad_config_3.1.0.toml +++ b/docs/example_configs/zebrad_config_3.1.0.toml @@ -76,7 +76,7 @@ network = "Testnet" peerset_initial_target_size = 25 [rpc] -cookie_dir = "/home/cupress/.cache/zebra" +cookie_dir = "" debug_force_finished_sync = false enable_cookie_auth = false max_response_body_size = 52428800 @@ -85,7 +85,7 @@ listen_addr = '127.0.0.1:18231' indexer_listen_addr = '127.0.0.1:18232' [state] -cache_dir = "/home/cupress/.cache/zebra" +cache_dir = "" delete_old_database = true ephemeral = false should_backup_non_finalized_state = true diff --git a/docs/internal_spec.md b/docs/internal_spec.md index 97105cba..92673ef6 100644 --- a/docs/internal_spec.md +++ b/docs/internal_spec.md @@ -132,8 +132,8 @@ Full documentation for `ZainoD` can be found [here](https://zingolabs.github.io/ - Communicates with `zaino-state` to retrieve data. - The served JSON-RPC schema (`rpc/jsonrpc/wire/`): - - Owns the JSON shape Zaino emits for its zcashd-compatible RPC surface — - serde structs with zcashd's exact field names, one `from_domain` conversion + - Owns the JSON shape Zaino emits for its legacy-compatible RPC surface — + serde structs with the legacy full node's exact field names, one `from_domain` conversion per type, and golden serialization tests beside each. - This is deliberately *not* shared with the shape Zaino accepts from a validator, which is `zaino-source-zebra-rpc`'s. The two interfaces @@ -142,7 +142,7 @@ Full documentation for `ZainoD` can be found [here](https://zingolabs.github.io/ - Error Handling: - Maps internal errors to appropriate gRPC status codes. - - Recovers zcashd-compatible legacy error codes by walking the error chain for + - Recovers legacy-compatible legacy error codes by walking the error chain for `zaino_source::FetchError` (a code the *validator* returned) or `zaino_state::LegacyRpcError` (Zaino's own rejection). - Provides meaningful error messages to clients. @@ -210,7 +210,7 @@ and each crate's `usage.md` for practical guidance. - One trait per question a consumer can ask, each with its own error type. - `QueryError` separates a *domain answer* (the validator replied; not retried) from a *transport failure* (retried by `Resilient`, according to a - machine-readable `FailureMode`). This is what lets a zcashd legacy error + machine-readable `FailureMode`). This is what lets a legacy full-node legacy error code survive from the validator to the served response. - Capability is structural: an adapter that cannot answer a question does not implement its port, so mis-routing is a compile error. diff --git a/docs/logging.md b/docs/logging.md index 76cbb761..bbd58581 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -58,22 +58,22 @@ RUST_LOG="zaino_state=debug,zaino_serve=info,zebra_state=warn" zainod start ZAINOLOG_COLOR=false zainod start 2>&1 | tee zainod.log ``` -### Makefile / Container Tests +### Tests -The test environment passes logging variables through to containers: +The test environment passes logging variables through: ```bash # Default (stream format) -makers test +cargo nextest run # Tree format in tests -ZAINOLOG_FORMAT=tree makers test +ZAINOLOG_FORMAT=tree cargo nextest run # Debug logging in tests -RUST_LOG=debug ZAINOLOG_FORMAT=tree makers test +RUST_LOG=debug ZAINOLOG_FORMAT=tree cargo nextest run # JSON output for parsing test logs -ZAINOLOG_FORMAT=json makers test 2>&1 | jq . +ZAINOLOG_FORMAT=json cargo nextest run 2>&1 | jq . ``` ### Production diff --git a/docs/perf.md b/docs/perf.md new file mode 100644 index 00000000..1b6c5465 --- /dev/null +++ b/docs/perf.md @@ -0,0 +1,369 @@ +# Zaino performance + +Three numbers, and the configuration that produced them. The numbers are +meaningless without the configuration, so the two live in one document and are +updated together. + +Everything here is produced by `zaino-bench` — see +[`packages/zaino-bench/usage.md`](../packages/zaino-bench/usage.md) for the run +procedure. Every result is measured on the **Direct backend**: Direct reads +Zebra's `ReadStateService` in-process, which is the fastest path Zaino has and +therefore the honest ceiling to quote. It requires zainod and zebrad on the same +host. + +The serve and concurrency sections are reported **twice**, under the two +finalised-state modes, because they measure different machinery: + +| Mode | `ephemeral_finalised_state` | What answers a finalised read | +|---|---|---| +| Persistent | `false` | Zaino's own finalised-state index | +| Ephemeral | `true` | passthrough to the validator | + +Initial sync is a persistent-mode measurement only — in ephemeral mode there is +no index to build. + +> **Status: all three sections measured.** The concurrency figures in section 2 +> are 5,000 connections at 100% success in both modes — but 5,000 is the largest +> count tested, not a ceiling, and it is bounded by an LMDB reader limit this +> branch changed. Read "The ceiling is the LMDB reader table" before quoting a +> supported-connection number. +> +> **These numbers are not `dev`.** They were produced on +> `add_sync_plus_concurrency_tests` at commit `14720e74`, which carries three +> bulk-sync performance changes not yet on `dev` (pipelined batch commits, +> concurrent block fetch, concurrent block assembly). A `dev` measurement would +> be materially slower and is not recorded here. + +--- + +## 1. Initial sync — mainnet, from genesis + +`zaino-bench sync`, against a fully synced mainnet zebrad, with Zaino's +finalised-state database removed beforehand. Persistent mode +([`zainod-bench-mainnet.toml`](example_configs/zainod-bench-mainnet.toml)). + +| Configuration | Blocks synced | Wall-clock | Mean blocks/s | +|---|---|---|---| +| Direct backend (`backend = 'direct'`) | 3,337,015 | **4h 43m** (16,981s) | **196** | +| RPC backend (`backend = 'rpc'`) | TBD | TBD | TBD | + +The RPC row is worth having for contrast: it is what Zaino can do against a +validator it does not share a machine with, and the gap between the rows is the +value of co-location. It is optional — record it only if a second full sync is +worth the wall-clock. + +### The mean hides the shape of the run + +Sync rate varies by more than two orders of magnitude across the chain, and one +narrow band of heights dominates the wall-clock: + +| Region | Heights | Blocks | Wall-clock | Blocks/s | Share of blocks | Share of time | +|---|---|---|---|---|---|---| +| Pre-sandblast | 107,349–1,705,519 | 1,598,170 | 12m 23s | 2,151 | 48% | 4% | +| **Sandblast** | 1,705,519–1,823,494 | 117,975 | **3h 12m** | **10.3** | **3.5%** | **68%** | +| Post-sandblast | 1,823,494–3,444,364 | 1,620,870 | 1h 19m | 342 | 49% | 28% | + +**The sandblast heights are 3.5% of the chain and 68% of the sync.** A mean +blocks/s figure quoted without this is close to meaningless: the same node does +2,151 blocks/s before height 1.7M and 10 blocks/s just after it. + +The cause is measured, not inferred. A profile through that band puts ~91% of +cycles in BLS12-381 scalar arithmetic — `sqrt_tonelli_shanks`, `Scalar::square`, +`Scalar::invert` — against ~1% in LMDB. That is Jubjub point decompression: +zebra's deserializer resolves `cv` and `ephemeral_key` into curve points for +every Sapling output, and Zaino re-serialises them to store the compact form. +Sandblast-era blocks carry hundreds of outputs each. Both directions are work +Zaino does not need — the bytes are already on disk in compressed form — and +removing rather than parallelising it is an upstream `zebra-chain` change (lazy +`cv` / `ephemeral_key` decompression). + +### Caveats on these numbers + +Three things about how this run was measured, all of which affect how much +weight the figures carry: + +- **The first 107,349 blocks are not in the window.** `zaino.sync.finalized_height` + is only emitted once the write loop's throttled progress branch first fires, + so the harness's t0 lands after the node has already synced its first batch. + Wall-clock from zainod start is roughly 50s longer than the 16,981s recorded. +- **The run ended on the harness's stall timeout, not on completion.** The + durable tip (`zaino.db.tip_height`) reached the target 3,454,128 — the sync + finished — but the fetch-pointer gauge the harness watches stopped updating at + 3,444,364, so `--stall-timeout-secs` fired 890s later. That final flat period + is excluded above; no sync work was outstanding during it. Two harness bugs to + fix before the next run: completion should be read from `zaino.db.tip_height` + rather than the fetch pointer, and the `--csv` curve should be written even + when the run ends in an error (it currently is not, so this run has no curve). +- **The index is 76 GiB, not ~275 GiB.** That is the measured on-disk size at + the target height, and it contradicts the estimate in + `zainod-bench-mainnet.toml`. The config comment should be corrected. + +Sync curve: not captured for this run (see the second caveat above). When it is, +`--csv` writes `elapsed_secs, +finalized_height, target_height, lag_blocks, node_lag_gauge, db_tip_height, +chain_tip_height, transactions_total, interval_blocks_per_sec`. `lag_blocks` is +derived as `target - finalized`; `node_lag_gauge` is the node's own +`zaino.sync.lag_blocks`, which reports the seam depth rather than the sync lag. + +## 2. Concurrent connections + +`zaino-bench concurrent --sweep`, against the synced instance. Each connection +streams 1000 blocks from its own window of the pool. + +Both sweeps used `--blocks 200` over the pool `3000000..=3380000`, with every +connection held open at a barrier until all of them had dialled, so the fetch +phase begins with the full connection count established. + +### 2a. Persistent finalised state + +| Connections | Success | Wall-clock | Mean fetch | Aggregate blocks/s | Chain breaks | Mean in flight | +|---|---|---|---|---|---|---| +| 100 | 100% | 0.20s | 0.098s | 100,088 | 0 | 49 | +| 500 | 100% | 0.48s | 0.315s | 209,945 | 0 | 328 | +| 1000 | 100% | 0.97s | 0.666s | 205,890 | 0 | 687 | +| 2000 | 100% | 2.03s | 1.877s | 196,684 | 0 | 1,849 | +| 5000 | 100% | 4.95s | 4.027s | 201,936 | 0 | 4,068 | + +**5,000 concurrent connections at 100% success, no chain breaks — and that is +the largest count tested, not a measured ceiling.** The knee is somewhere above +5,000; this run did not find it. + +Aggregate throughput plateaus at **~200,000 blocks/s** from 500 connections +onward while mean fetch latency scales linearly with the connection count +(0.098s → 4.027s). That is a saturated server queuing gracefully: added clients +wait longer, they do not fail. + +### 2b. Ephemeral finalised state + +| Connections | Success | Wall-clock | Mean fetch | Aggregate blocks/s | Chain breaks | Mean in flight | +|---|---|---|---|---|---|---| +| 100 | 100% | 2.19s | 1.761s | 9,150 | 0 | 80 | +| 500 | 100% | 9.53s | 9.198s | 10,494 | 0 | 483 | +| 1000 | 100% | 20.58s | 20.208s | 9,720 | 0 | 982 | +| 2000 | 100% | 42.72s | 42.284s | 9,364 | 0 | 1,980 | +| 5000 | 100% | 110.28s | 109.522s | 9,068 | 0 | 4,966 | + +**Also 5,000 at 100%, and also not a ceiling.** Aggregate throughput is flat at +~9,400 blocks/s from 100 connections on — **~21× below persistent** — which +places the limit in the validator passthrough rather than in Zaino's request +handling. Mean fetch reaches 109s at 5,000 connections, well past the 30s +`service.timeout`, so these are slow but not failing. + +### Reading these tables + +Three things determine how much weight they carry: + +- **They are genuine concurrency measurements, unlike an earlier attempt.** The + "mean in flight" column is `connections × mean fetch ÷ wall-clock` — what was + actually open at once. It tracks the nominal count closely (4,068 of 5,000 + persistent, 4,966 of 5,000 ephemeral). Before the harness held connections at a + barrier, the same sweep never had more than 38 open regardless of the count + asked for, because short reads retired faster than the ramp created them. +- **The client was the tighter constraint at 5,000.** `ulimit -n` was 8192 during + these runs, and 5,000 connections need roughly 10,000 descriptors — the harness + warned. So the 5,000 row is bounded at least partly by the client, and a rerun + above `ulimit -n 32768` may well go further. +- **The persistent figures are warm-cache.** The first run after a restart gave + ~110,000 blocks/s aggregate against ~200,000 on the two subsequent runs; the + table reports the steady state. Two consecutive warm runs agreed within 5%. + +### The ceiling is the LMDB reader table, and exceeding it restarts the node + +The concurrency limit is not a throughput property — it is the size of LMDB's +reader table, and it is worth stating plainly because the failure mode is bad. + +The environment is opened with `NO_TLS`, so a reader slot belongs to a read +*transaction* rather than a thread: every concurrent read holds one. Slots are +sized by `(cpu × 32).clamp(MIN_LMDB_READERS, MAX_LMDB_READERS)`. **On this +16-core host the applied value is 2,048** — the clamp floor, since `16 × 32 = +512` falls below it. Confirmed by measurement: `lock.mdb` is 131,200 bytes, +which is `2048 × 64B` plus a 128-byte header. + +An earlier attempt at this sweep ran against the previous floor of 512 and did +not merely fail — it **restarted the node in a loop**. Reads failed with +`MDB_READERS_FULL`, the startup block scan +(`finalised_source/v1.rs`) treats any error from it as fatal and stored +`CriticalError`, the indexer restarted into the same load, and the cycle +repeated. The gRPC port disappeared and every client saw a transport error. + +**Raising the floor to 2,048 is what made these tables possible; it did not fix +the underlying defect.** A client can still open more concurrent reads than +there are slots, and `MDB_READERS_FULL` — which is backpressure, not corruption — +is still classified as critical. Until that classification changes, any client +can restart a Zaino node by exceeding whatever limit is configured. Treat the +supported-connection figures here as properties of *this configuration*, not of +Zaino. + +### Knobs that bound these numbers + +Both sweeps were run at **default** settings. These cap concurrency directly, so +a number quoted without them is not reproducible: + +| Knob | Where | Value used | +|---|---|---| +| `service.channel_size` | `ZainodConfig` | 32 (default) | +| `service.timeout` | `ZainodConfig` | 30s (default) | +| `storage.cache.capacity` | `ZainodConfig` | 10000 (default) | +| `storage.database` LMDB `max_readers` | derived, `finalised_source/v1.rs` | **2048** (the clamp floor; `16 cores × 32 = 512` falls below it) | +| Client `ulimit -n` | test host | **8192 — below the ~10,000 that 5,000 connections need** | + +Both bottom rows bound the results rather than describing the host. `max_readers` +is the concurrency ceiling (see above), and at `ulimit -n 8192` the client cannot +hold 5,000 connections' worth of descriptors — so the 5,000 row is bounded at +least partly by the test client. Raise both before treating any figure here as +Zaino's limit. + +If a tuned run is also recorded, it goes in its own table naming the knob that +moved — a tuned number presented as the default is misleading. + +## 3. Block serve rate + +`zaino-bench serve`, one connection, one `GetBlockRange` stream, timed from the +request. The same pass verifies every `prev_hash` link. + +| Mode | Range | Blocks | Wall-clock | Blocks/s | Payload MB/s | Errors | +|---|---|---|---|---|---|---| +| Persistent | 3300000..=3454000 | 154,001 | 1.00s | **154,203** | 99.9 | 0 | +| Ephemeral | 3300000..=3454000 | 47,821 (of 154,001) | 120.00s | **399** | 0.2 | timed out | + +Both modes verified every `prev_hash` link over the blocks they delivered — the +chain is valid in each case, including the truncated ephemeral stream. + +**Persistent serves finalised blocks ~390× faster than ephemeral.** That is the +whole point of the index: in persistent mode the read is answered from Zaino's +own finalised state, in ephemeral mode it is a passthrough to the validator. The +ephemeral run did not finish — it hit the gRPC deadline after 47,821 blocks and +120s, which is itself the finding: a full-range `GetBlockRange` over 154,001 +blocks is not serviceable by passthrough within the client's timeout. + +Aggregate (multi-connection) throughput is in the sweep tables in section 2; this +section is the single-stream figure. Note the two are not directly comparable — +the sweeps draw from a 380,001-block pool with heavy range overlap at high +connection counts, so cache hit rates differ. + +--- + +## Test configuration + +Every field here changes the numbers above. + +**Host** + +| | | +|---|---| +| CPU | AMD Ryzen 7 7840U, 16 threads | +| RAM | 61 GiB | +| Disk | **WD_BLACK SN850X NVMe** (LUKS + LVM, mounted at `/`) — zebrad's and Zaino's databases share this volume | +| OS / kernel | Linux 6.1.0-40-amd64 (Debian) | +| `ulimit -n` (client) | 1048576 | +| `net.ipv4.tcp_slow_start_after_idle` | `0` | +| zebrad co-located | yes — Direct backend requires it | + +**The disk matters more than anything else in this table.** An earlier attempt at +this run used a USB-attached external drive (reported rotational, queue depth +60), which served random reads at 2,577 IOPS / 0.31 ms against the SN850X's +0.096 ms. On that volume the batch commit alone took 162s per 100k blocks and +consumed 93% of wall-clock; on NVMe it is 38.5s per 100k and ~85%. No number in +this document should be quoted against a non-NVMe volume. + +`tcp_slow_start_after_idle` defaults to `1`, which resets TCP's congestion +window after an idle period. zebrad warns about it at startup for its own block +fetching, and it applies just as much to the load generator, whose connections +are idle between spawn and fetch. Set it to `0` before measuring and record +which value was in force: + +```sh +sudo sysctl -w net.ipv4.tcp_slow_start_after_idle=0 +``` + +**Versions** + +| | | +|---|---| +| zaino | `14720e74` (branch `add_sync_plus_concurrency_tests`, **not `dev`** — see the status note) | +| zebrad | 6.3.0, git `f5c5277`, opt-level 3, **`debug checks: true`** | +| Zebra state format | v28 (matches `zebra-state 13.0`; no migration on open) | +| rustc | see `rust-toolchain.toml` | + +The zebrad build has `debug_assertions` compiled in, which slows the validator. +It affects the Direct-backend rows least — Zaino reads Zebra's database rather +than asking it questions — but it bounds the optional RPC-backend row and +zebra's ability to hold the tip during a long run. Quote it alongside the +numbers; a validator build is part of the configuration. + +**zainod** — [`zainod-bench-mainnet.toml`](example_configs/zainod-bench-mainnet.toml) +and [`zainod-bench-mainnet-ephemeral.toml`](example_configs/zainod-bench-mainnet-ephemeral.toml), +built with `--features prometheus` (required for the `sync` measurement). + +**zebrad** — `[state] cache_dir = ''`, +`[rpc] listen_addr = '127.0.0.1:18232'`, `indexer_listen_addr = '127.0.0.1:18230'`. +Record any other non-default `[state]` or `[sync]` settings. + +**Validator must be holding the tip.** Confirm before each run, and record the +connected peer count — a validator running on a handful of peers can fall behind +mid-measurement, which moves the sync target and leaves the concurrency sweep +reading a chain that is still advancing: + +```sh +curl -s -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getblockchaininfo","params":[]}' \ + http://127.0.0.1:18232 | python3 -m json.tool | grep -E 'blocks|headers|verificationprogress' +curl -s -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getpeerinfo","params":[]}' \ + http://127.0.0.1:18232 | python3 -c 'import json,sys; print("peers:", len(json.load(sys.stdin)["result"]))' +``` + +A stale `network/mainnet.peers` cache is the usual cause of a low peer count: +delete it and let the DNS seeds repopulate. Note that `[network] cache_dir = true` +means "the default directory", so the peer cache does not follow +`[state] cache_dir` onto the same volume. + +**Commands** + +```sh +# 0. Host prep, then build. zainod needs the prometheus feature for step 1. +sudo sysctl -w net.ipv4.tcp_slow_start_after_idle=0 +cargo build --release -p zainod --features prometheus +cargo build --release -p zaino-bench + +# 1. Initial sync, persistent mode. Start the harness first, so t0 is the +# moment zainod starts. Removing the database is what makes it an *initial* +# sync rather than a catch-up — check free space first (see below). +rm -rf /* +makers bench sync --csv sync-mainnet.csv +zainod start --config docs/example_configs/zainod-bench-mainnet.toml + +# 2 + 3. Against the now-synced instance, persistent mode. +ulimit -n 32768 # must exceed 2 x the largest sweep value +makers bench concurrent \ + --server http://127.0.0.1:8137 \ + --start-height 3000000 --end-height 3380000 \ + --blocks 200 --sweep 100,500,1000,2000,5000 +# --end-height pinned, not defaulted to the tip: the two modes report different +# tips, and an unpinned range makes the persistent and ephemeral rows different +# amounts of work. +makers bench serve --server http://127.0.0.1:8137 \ + --start-height 3300000 --end-height 3454000 + +# 4. Restart zainod on the ephemeral config, then repeat 2 + 3 unchanged. +zainod start --config docs/example_configs/zainod-bench-mainnet-ephemeral.toml +``` + +**Disk space.** Measured on this run: the Zaino index is **76 GiB** at height +3,454,128, alongside zebrad's 260 GiB on the same volume. (The ~275 GiB figure in +`zainod-bench-mainnet.toml` is a large overestimate and should be corrected.) +Confirm the free space covers a fresh sync *before* starting one — the sync run +is long enough that losing it partway hurts. + +--- + +## Prior art + +The `concurrent` and `serve` tools were ported from the `zaino-admin` binary on +the `hahn/store` branch (`hhanh00/zaino`, commit `14401f07`), which measured a +different indexer — an LMDB block store that branch also introduced — against a +Zaino of that vintage. Those numbers are not comparable to the ones above and are +deliberately not reproduced here: this document measures the `dev` indexer on the +Direct backend. The harness, the sweep, the tail percentiles, the ephemeral / +persistent split, and the metrics-based sync measurement are additions on this +side. diff --git a/docs/release/changeset-format.md b/docs/release/changeset-format.md new file mode 100644 index 00000000..07de5e67 --- /dev/null +++ b/docs/release/changeset-format.md @@ -0,0 +1,348 @@ +# Changeset Format + +> Sub-spec of [pipeline.md](./pipeline.md). Defines the `.changesets/` file +> contract: what a contributor writes, how CI aggregates it into per-crate +> version bumps and changelogs. The CLI that reads/writes these files +> (`tools/relman`) is specified separately; this document is about the **data**, +> not the tool. + +## Status + +Draft for review. Field design confirmed: entries declare a semantic +[`kind`](#decision-semantic-kind-not-literal-bump) (not a literal semver bump), +refining the illustrative `bump = "minor"` shown in `pipeline.md`. Filenames are +[PR-aligned via a two-phase rename](#file-location--naming). + +## What a changeset is + +Every PR that changes the source of a governed (published) crate adds **one +changeset file** describing what it changes, per crate, at a semantic level. The +file is the contributor's entire release-facing responsibility: from the +accumulated changesets since the last release, CI derives every crate's version +bump and every changelog line. See [pipeline.md § Framing +Principle](./pipeline.md#framing-principle). + +## File location & naming + +Files live in `.changesets/` at the repo root, extension `.toml`, **one file per +PR**. The name is **two-phase**, so authoring never blocks on a PR number that +doesn't exist yet: + +1. **At authoring time** (before the PR exists) `relman changeset new` creates + `.changesets/.toml` with a unique random slug (e.g. + `wandering-quokka`). Uniqueness means two concurrent PRs add *different* files + and never merge-conflict. +2. **Once the PR is opened**, a `dev`-gate bot renames the file to + `.changesets/pr-.toml` (N = the PR number) and pushes the rename to the PR + branch. PR numbers are inherently unique, so canonical names stay + conflict-free, and browsing `.changesets/` shows at a glance which PR each + pending change came from. The rename is idempotent (a no-op once canonical). + +The filename is a **readability/audit convention, not a parsed source of +truth**: changelog PR-linking comes from CI's PR context, so a file that hasn't +been renamed yet still links correctly. Fork PRs — where the bot cannot push to +the contributor's branch — simply keep their slug; the check still passes and +linkage still works. + +### Machine identity: the immutable `id` + +Because the filename is a *mutable* convention (slug → `pr-`), it cannot serve +as a stable identity. So every changeset also carries a machine-assigned +`id = ""` (a time-sortable UUIDv7), written once by `relman changeset new` +and **never edited thereafter** — the slug rename moves the file without touching +its content, and consumption only *appends* the `consumed_in` mark, so the `id` +survives both. The `id` is distinct in purpose from the slug: the **slug** is the +human/PR-facing handle, the **`id`** is the durable identity a machine dedups on. + +The field is optional in the schema (a hand-written or legacy changeset without +one still parses), and today nothing yet consults it — it is deliberate +groundwork. It is the identity primitive for the **consumed-UID ledger** (see +[§ Consume by marking](#consume-by-marking-not-erasing)): a future increment +records shipped `id`s in a ledger on `stable`, so derivation can reject an +already-shipped changeset **independent of the backport** having delivered its +per-file `consumed_in` mark. Baking the `id` in now — while the pipeline is +pre-launch and no real changeset has shipped — is cheap; retrofitting stable +identity onto already-released changesets later would not be. + +On release, `relman` **marks** the aggregated changesets consumed (it stamps +each with the cycle that shipped it) rather than deleting them. Derivation +filters consumed changesets out, so the next cycle sees an effectively empty set +while `.changesets/` retains the full provenance ledger. See [§ Lifecycle](#lifecycle-read-on-every-derivation-consumed-only-at-release). + +## File structure + +A changeset is a TOML file containing an array of `[[changes]]` entries. Nothing +else is required. + +```toml +[[changes]] +crate = "zaino-state" +kind = "feature" +description = "Add a parallel block-sync mode selectable via config." + +[[changes]] +crate = "zainod" +kind = "feature" +description = "Expose the parallel sync mode as a `--sync-mode` CLI flag." +``` + +A single PR (one file) may declare changes to multiple crates. + +### Fields + +| Field | Required | Type | Meaning | +| ------------- | -------- | ------ | ------------------------------------------------------------------ | +| `crate` | yes | string | A governed (published) crate name. Validated against the workspace. | +| `kind` | yes | enum | Semantic intent of the change (see below). **Not** a literal semver level. | +| `description` | yes | string | One operator-facing changelog line. Multiline allowed. Plain language, no invented jargon. | +| `section` | no | enum | Keep-a-Changelog section override (`Added`/`Changed`/`Fixed`/`Removed`/`Security`/`Deprecated`). Defaults are derived from `kind`. | +| `migration` | no | string | Migration/upgrade notes. Expected on `breaking`; rendered in a "Breaking changes" block. | +| `issues` | no | array | Issue references, e.g. `["#987"]`. The PR number is linked automatically at merge; this is for *additional* refs. | + +- `crate` must be a **versioning target declared in `relman.toml`** + ([Implementation § relman.toml](./implementation.md#relmantoml--the-versioning-target-manifest)) — + that manifest, not a `cargo metadata` heuristic, is the authority for what is + governed. Non-target crates (`e2e`, `clientless`, `zaino-testutils`, and any + crate not listed) are never changeset subjects; a change there that *forces* a + change in a declared target is recorded against the **target**. +- `description` is written to stand alone as a changelog bullet. Each entry is + exactly one bullet. + +## Decision: semantic `kind`, not literal `bump` + +The author declares the **kind of change**, not the resulting version bump: + +| `kind` | Meaning | +| ---------- | ------------------------------------------------------------- | +| `breaking` | Backward-incompatible change to a governed public interface. | +| `feature` | Backward-compatible addition. | +| `fix` | Backward-compatible bug/perf fix. | +| `internal` | No externally observable contract change (refactor, internal). | + +CI maps `kind` → a literal semver bump **per crate**, applying the [pre-1.0 +relaxation](./pipeline.md#versioning-semantics-inherited-from-adr-003-2) based +on that crate's *current* version: + +| `kind` | post-1.0 crate | pre-1.0 crate (0.y.z) | +| ---------- | -------------- | --------------------- | +| `breaking` | major | **minor** | +| `feature` | minor | **patch** | +| `fix` | patch | patch | +| `internal` | patch | patch | + +**Why intent, not literal bump:** + +1. **The relaxation is applied exactly once, by the tool.** If authors wrote + literal `bump = "minor"`, it would be ambiguous whether they already + discounted for pre-1.0 — and easy to double-apply or forget. Declaring + `kind = "breaking"` is unambiguous; the tool owns the 0.x policy. +2. **Declarations survive the 1.0 boundary.** A crate crossing 0.x → 1.0 does + not require rewriting past changesets: the same `kind` simply maps to a + different bump afterward. Literal bumps would silently mean the wrong thing. +3. **It matches the framing principle** — humans state facts about their change; + CI derives the numbers. + +`internal` additionally marks an entry as **not user-facing**: it forces a patch +bump but is rendered in an "Internal" changelog subsection (or omitted from the +public changelog per rendering config), so a refactor doesn't masquerade as a +user-visible change while still moving the version. + +## Per-public-change granularity + +When a PR changes more than one [governed public +interface](./pipeline.md#governed-public-interfaces-inherited-from-adr-003-5), +each such change is its **own `[[changes]]` entry** — even for the same crate at +the same `kind`. The aggregated changesets are the source of the changelogs, and +every user-visible change must be listable individually. + +Internal-only changes within a governed crate may be **collapsed** into a single +`kind = "internal"` entry describing the net effect. + +Transitive bumps are **never authored**: if crate A depends on crate B and B's +bump crosses A's compatibility boundary, CI adds the forced bump on A during +aggregation ([pipeline.md § Transitive version +bumps](./pipeline.md#transitive-version-bumps)). Authors only declare their own +direct changes. + +## Aggregation semantics + +At any moment (for the release PR body) and at blessing (for the final versions), +`relman` aggregates every changeset in `.changesets/`: + +1. **Group** all `[[changes]]` by `crate`. +2. **Resolve the bump per crate**: take the highest `kind` + (`breaking > feature > fix > internal`), then map to a literal semver bump + using the crate's current version and the table above. +3. **Add transitive bumps** for dependents whose requirement crosses a + compatibility boundary (patch, unless a stronger bump already applies). +4. **Collect descriptions** per crate for the per-crate changelog; the union + feeds the workspace changelog. + +Because changesets only accumulate within a cycle and resolution is +highest-wins, each crate's derived bump is **monotonically non-decreasing** +across the cycle — it never flaps. See [pipeline.md § Derivation is +monotonic](./pipeline.md#derivation-is-monotonic-within-a-cycle). + +## Lifecycle: read on every derivation, consumed only at release + +Aggregation is **read-only and idempotent**. Every prerelease / RC cut, and +every refresh of the release-PR body, re-reads the **entire** current +`.changesets/` set to derive versions and notes — it never removes files. An RC +must be informed by the *whole* set, because an RC is a candidate for *the* +release, and the release's bump is a function of everything accumulated since the +last release; a partially-consumed set would under-count a later RC. + +Only a **true release (blessing)** consumes the changesets: after the release PR +merges into `stable`, `relman changeset consume --cycle ` stamps each pending +changeset with `consumed_in = "cycle-"`. In short — **prereleases use, the +release consumes** — and consumption happens exactly once per cycle, at the +blessing. + +### Consume by marking, not erasing + +Consumption **marks** files; it never deletes them. A consumed changeset stays +on disk carrying its `consumed_in` stamp, and every derivation **filters out any +changeset with `consumed_in` set** — exactly as it skips an unfilled template. +Three reasons this beats erasing: + +- **Self-defending aggregation.** A released changeset that lingers in + `.changesets/` (a lagging backport, a stray cherry-pick) is *inert*, not + silently re-counted. Erasing has no such defense: a present file is always + counted, so a missed cleanup corrupts the next cycle's derived version — the + worst failure class, because it is invisible. Marking degrades that to a + cosmetic stale file. +- **Merge-safe delivery.** The stamp is written on `stable` at the blessing, but + `dev` is the root of the branch flow (`dev → rc → release-ready → stable`), so + the stamp — like the version bumps and changelog edits — only reaches `dev` + via the **stable → dev backport** ([pipeline.md § Hotfix / backport](./pipeline.md)). + Replaying an *additive* `consumed_in` stamp 3-way-merges cleanly; replaying a + *deletion* races badly against any changeset `dev` touched in the interim + (delete/modify conflict). Marking makes the backport forgiving; the backport is + still what delivers consumption to `dev`. +- **Provenance ledger.** `.changesets/` records which cycle each change shipped + in; `git log .changesets/pr-.toml` tells the whole story without spelunking + release tags. + +`relman changeset clear` still exists as a manual garbage-collect for pruning old +consumed changesets; it is not part of the release path. + +### The consumed-UID ledger (backport-independent dedup) + +The per-file `consumed_in` mark only reaches `dev` once the backport lands. The +**consumed-UID ledger** removes that dependency: consumption also records each +shipped changeset's [`id`](#machine-identity-the-immutable-id) in a single ledger +file (default `.release/consumed-ledger.toml`), and **every derivation also +excludes any changeset whose `id` is in the ledger** — on top of the `consumed_in` +skip. So a released changeset that lingers on `dev` *without* its mark is still +inert, because its `id` is ledgered. + +```toml +# .release/consumed-ledger.toml — appended once per changeset at the blessing. +[[consumed]] +id = "018f4e0a-7b2c-7c3d-8e4f-1a2b3c4d5e6f" +cycle = "cycle-1" +slug = "pr-9" +``` + +**relman stays a pure function of files:** it reads the ledger from its configured +path and never consults git. CI supplies the authoritative content: + +- **At the blessing** (on `stable`), before deriving and consuming, CI restores + the ledger from the **last released `cycle-*` tag** — so the accumulation is + clean regardless of what the release merge dragged in — then `consume` appends + this cycle's ids and the release commit carries the ledger forward on `stable`. +- **When deriving off another branch** (e.g. `release-pr-body` on `release-ready`), + CI overwrites the working-tree ledger with `git show origin/stable:…` first, so + the derived table reflects everything `stable` has shipped **without waiting for + a merge**. + +The ledger only grows, only on `stable`, only at a blessing; other branches treat +their copy as advisory and CI refreshes it from the authoritative ref before any +derivation that must be exact. This makes dedup correct even if the `stable → dev` +backport never runs — the backport becomes a convenience (keeping dev's manifests +and changelogs current), not a correctness dependency. + +**Out of scope — a running (chained) hash.** A hash-of-hashes accumulator over the +ledger would add tamper-evidence, but that is a supply-chain-integrity concern, +not double-consume prevention (a threat we do not have). On record; not planned. + +## Changelog rendering + +- Default section per `kind`: `feature → Added`, `fix → Fixed`, + `breaking → Changed` (or `Removed`, via the `section` override), + `internal → Internal` (or omitted). `section` overrides the default. +- Each `description` becomes one bullet under its crate's changelog and the + workspace changelog, with the PR auto-linked. +- `breaking` entries additionally render their `migration` note in a + per-release "Breaking changes" block. +- The ZainoDB on-disk-schema changelog stays on its **own cadence** and is + **not** driven by these changesets ([pipeline.md § Changelog + policy](./pipeline.md#changelog-policy-inherited-from-adr-003-4)). + +## Enforcement + +A `dev`-gate CI check (`relman changeset check`) fails a PR when: + +1. The PR changes **source of a declared target** but no changeset entry covers + it. The check maps changed file paths → owning target using each target's + `path` in `relman.toml`; every target with changed source must appear in ≥1 + `[[changes]]` entry (of any `kind`). +2. A changeset names a `crate` that is not a declared target, or uses an unknown + `kind`/`section`. +3. A `[[changes]]` entry is missing a required field. + +### Escape hatch: the empty changeset + +A PR that touches governed-crate source but is genuinely release-irrelevant +(e.g. a comment-only or test-only change) records an **empty changeset** — a +file with no `[[changes]]` and a required reason: + +```toml +# .changesets/.toml +[empty] +reason = "Comment-only fix in zaino-state; no behavioural or API change." +``` + +This satisfies enforcement without forcing a spurious patch bump and leaves an +auditable justification. `relman changeset new --empty ""` creates it. +Empty changesets contribute nothing to versions or changelogs and are marked +consumed like any other on release. + +## Worked example + +Two PRs land in a cycle. `zaino-state` is at `0.3.1`, `zainod` at `0.4.3` +(both pre-1.0). + +`.changesets/wandering-quokka.toml` (PR #1501): + +```toml +[[changes]] +crate = "zaino-state" +kind = "breaking" +description = "Replace the `sync()` entrypoint with `sync_with(SyncMode)`." +migration = "Call `sync_with(SyncMode::Serial)` for the previous behaviour." + +[[changes]] +crate = "zaino-state" +kind = "fix" +description = "Stop double-counting orphaned blocks in the tip height gauge." +``` + +`.changesets/brisk-heron.toml` (PR #1509): + +```toml +[[changes]] +crate = "zainod" +kind = "feature" +description = "Add a `--sync-mode` flag wiring the new parallel sync mode." +``` + +Aggregated (pre-1.0 mapping): + +| Crate | Highest kind | Current | Next | Notes | +| ----------- | ------------ | ------- | ----- | ------------------------------ | +| zaino-state | breaking | 0.3.1 | 0.4.0 | breaking → minor (pre-1.0) | +| zainod | feature | 0.4.3 | 0.4.4 | feature → patch (pre-1.0); plus a transitive check against zaino-state's boundary crossing | + +zaino-state's changelog gets both its bullets (the breaking one with its +migration note); zainod's gets one. The workspace changelog gets all three. diff --git a/docs/release/implementation.md b/docs/release/implementation.md new file mode 100644 index 00000000..8c09b907 --- /dev/null +++ b/docs/release/implementation.md @@ -0,0 +1,302 @@ +# Release Pipeline — Implementation Architecture + +> Sub-spec of [pipeline.md](./pipeline.md). Where `pipeline.md` states the +> *what* and *why* (branches, gates, identity, hotfix), this document states the +> *how*: which execution substrate runs each piece, the boundary between the +> `relman` CLI and the CI glue, and the GitHub↔cluster bridge for the deployment gate. +> No code specifics — responsibilities and boundaries only. + +## Execution substrates + +The pipeline is **not** "just CI." It spans two execution substrates plus a +bridge, because the `release`-gate (deployment) is a fundamentally different beast +from the in-repo gates — days-long live-chain deployments, not nextest runs. + +| Piece | Substrate | Notes | +| ----- | --------- | ----- | +| `dev`-gate (unit + integration + e2e smoke), changeset `check` + bot rename | **GitHub Actions** (nextest) | pre-merge, per-push, in-repo | +| `rc`-gate (full e2e), advance `rc` | **GitHub Actions** (nextest, nightly) | in-repo, scheduled | +| changeset aggregation, version derivation, bump, changelog, release-PR body, tag planning, publish planning | **GitHub Actions + `relman`** | control-plane logic | +| branch protection, sentinels, CODEOWNERS | **GitHub** (rulesets) | platform-native | +| **`release`-gate (deployment / days-long live-chain runs)** | **the cluster — Argo** | ArgoCD + Argo Workflows + Argo Events; live chains; *not* nextest, *not* GH runners | + +**GitHub Actions is the control plane** (branches, versions, PRs, publish, +in-repo gates); **the cluster is the deployment data plane**. In-repo tests are +nextest-gated; the deployment gate is Argo-driven and evaluated against metrics, not a test +runner. + +## `relman`: functional core, imperative shell + +`relman` (a new sibling crate under `tools/`, see [pipeline.md § +Implementation](./pipeline.md#implementation)) is the **functional core**: it +makes every deterministic decision and performs every *working-tree* edit, with +**no network and no ref/remote mutation**, so it is unit-testable and safe to +run locally (dry by default). The CI YAML is the **imperative shell**: it only +applies `relman`'s outputs as side-effects on the outside world. + +### What `relman` owns + +| Subcommand (area) | Reads | Produces | Touches | +| ----------------- | ----- | -------- | ------- | +| `changeset new [--empty ]` | — | a `.changesets/.toml` scaffold | working tree | +| `changeset check` | git diff vs base, `.changesets/`, crate graph | pass/fail + diagnostics (enforcement) | nothing (read-only) | +| `changeset rename --pr [--base ]` | `.changesets/`, the PR's diff against `` | renamed `pr-.toml` | working tree | +| `derive` | `.changesets/`, all `Cargo.toml`, crate graph | per-crate next-version table (highest-`kind` + pre-1.0 map + transitive) | nothing (read-only) | +| `bump` | derive output | edited `Cargo.toml` versions + root `[workspace.dependencies]` pins (via `toml_edit`, format-preserving) | working tree | +| `changelog` | `.changesets/`, derive output | per-crate + workspace changelog edits | working tree | +| `pr-body` | derive output, deployment status input | rendered release-PR description | stdout/file | +| `tags` | derive output, cycle id | the tag set to apply (`-X.Y.Z`, `cycle-`, `cycle--rc.N`) | stdout/file | +| `publish-plan` | crate graph, crates.io state (reuse `workbench check-published-versions`) | topo-ordered publish list, skipping unchanged | stdout/file | +| `changeset clear` (release consume) | `.changesets/` | empties `.changesets/` | working tree | + +### What CI glue owns (and nothing more) + +The workflows **decide nothing**; they loop over `relman`'s emitted plans and +do the dumb execution: + +- commit the files `bump` / `changelog` / `rename` / `clear` wrote, and push; +- `git tag` each entry from `tags`, and push; +- `gh pr edit` with the `pr-body` output; open/advance the sentinel PRs; +- `cargo publish` in `publish-plan` order; +- build/push Docker images; create the GitHub Release; +- create GitHub **Deployments** and react to `deployment_status` (the + deployment-gate bridge, below). + +All *judgment* — which version, which tags, which changelog lines, which publish +order, whether a PR satisfies changeset enforcement — is in Rust, tested, and +runnable by a maintainer by hand. Bash never re-derives anything. + +### Not knope (or any external release manager) + +`knope` / `release-plz` / `cargo-release` are opinionated and expect you inside +*their* flow. Our model — version-agnostic branches, cycle tags, continuous +per-commit deployment, derived-only versions, PR-numbered changesets, semantic `kind` +— diverges enough that config-bending costs more than a small purpose-built +tool. `relman` is a handful of pure functions plus `toml_edit`. We borrow the +*idea* (changeset-driven derivation), not the tool. + +### `relman` internal structure (hexagonal) + +`relman` is generated from the `rust-cli-starter` `cargo-generate` template +(ports & adapters), as its **own isolated workspace** under `tools/relman/` +(kept out of the production graph, like `workbench`). The template's +functional-core/imperative-shell shape *is* the boundary specified above. + +| Crate | Role | +| ----- | ---- | +| `relman-core` | pure types (newtype-per-module, parse-don't-validate) + port traits | +| `relman-domain` | the derivation services (aggregation, highest-`kind` + pre-1.0 map, transitive bumps) — unit-tested against mocks. The heart. | +| `relman-config` | parses the committed `relman.toml` into a typed `ReleaseConfig` (targets + options) at the boundary | +| `relman-adapters` | concrete driven ports: fs changeset/changelog store, `toml_edit`/`cargo_metadata` workspace + manifest editor, git subprocess, crates.io index (reuse `workbench`) | +| `relman-cli` | clap delivery adapter — subcommands call driving ports via a `Ctx` | +| `relman` (bin) | composition root; the only place naming concrete adapters | + +- **Driven ports** (what relman needs): `Workspace`, `ChangesetStore`, + `ManifestEditor`, `ChangelogStore`, `Vcs`, `Registry`. **Driving ports** (what + relman offers, one per CLI concern): `Changesets`, `Versions`, `Bump`, + `Changelog`, `ReleaseArtifacts`. + +#### `relman.toml` — the versioning-target manifest + +A **repo-committed** manifest (not an XDG user config) is the single source of +truth for what relman governs. It lists each versioning target, its location, +and per-target options; `relman-config` parses it into typed core newtypes at +the composition root. + +```toml +# relman.toml — repo root +[options] +changesets_dir = ".changesets" +root_manifest = "Cargo.toml" # where [workspace.dependencies] pins live +workspace_changelog = "CHANGELOG.md" + +[[target]] +name = "zaino-state" +path = "packages/zaino-state" +# changelog defaults to /CHANGELOG.md; publish defaults to true + +[[target]] +name = "zainod" +path = "packages/zainod" +``` + +This is **the** authority for the governed-target set: changeset enforcement +validates a change's `crate` against the declared targets, versions/bumps are +applied only to declared targets, and the publish plan covers exactly them. No +`cargo metadata` heuristic decides governance. + +- **Pruned from the template:** the `installer` crate (relman runs in-repo, no + XDG install) and the `status`/`Health` demo thread. +- **Kept & repurposed:** the `config` crate (parses `relman.toml`, above); the + `Clock` driven port (relman needs "today" to date + `## [x.y.z] - YYYY-MM-DD` changelog headers); the `mocks`/`test-support` seam + (what makes the derivation services testable). +- **Conventions inherited:** no `mod.rs`, parse-don't-validate newtypes, depend + on `dyn Trait` (concretes only in the binary). + +## The deployment-gate bridge: GitHub Deployments ↔ Argo + +> **Status.** The **GitHub side of this bridge is built**: `rc-gate` creates the +> Deployment on a cut (step 1) and `deployment-advance.yml` reacts to +> `deployment_status` by fast-forwarding `release-ready` (step 6). The **cluster +> side** (Argo Events eventsource + sensor + the soak Workflow, steps 2–5) lives +> in the `devops` repo and is the remaining build. `tools/scripts/mark-deployment.sh` +> injects the `deployment_status` so the GitHub half is testable without the +> cluster. + +Each RC commit is dispatched to the **deployment gate** via a **GitHub +Deployment** targeting a dedicated `deployment` environment, executed by the +cluster. Routing it through GitHub's Deployments primitive makes each +deployment run native and visible (it shows on the commit and PR, gets +environment protection rules) — the "visible marker" story for the +`release`-gate. + +**GitHub → cluster (start a deployment run):** +1. On `rc`-gate pass, a GH Action creates a **GitHub Deployment** for the commit + + its `cycle--rc.N` image. +2. An **Argo Events** webhook eventsource + sensor catches the GitHub Deployment + event and fires the deployment **Argo Workflow**. +3. The 3–4 deployment slots + queue are an Argo Workflow **semaphore**; the + [coalesce-to-latest](./pipeline.md#the-release-gate-continuous-deployment) rule + lives in the sensor (drop a queued commit already superseded by a newer + `gate/candidate`). + +**cluster → GitHub (report result):** +4. The deployment Workflow boots mainnet **`zebra`** + the RC's **`zainod`** and + exercises it. Depth is a **dial, not a fixed days-long soak**: + - **warm-start** from the golden synced snapshot (`serve-zaino use-cache=true`) + and validate at the **tip** in minutes–hours — wallet-sync fixtures, sending + transactions, light-RPC probes; or + - **fresh full-index sync** from genesis (hours–days) when a release warrants it. + Validation is **automated** (fixtures + metrics: sync completed, no crash, perf + in bounds) **and/or manual** — a tester syncs a wallet against it, sends txs, + and signs off (an Argo `suspend` step or a GitHub environment approval). +5. Its final step reports **`deployment_status`** back to GitHub + (`in_progress` → `success` / `failure`). The poster is deliberately + unopinionated: the automated Workflow, a fixture job, or a **human** — the + GitHub side reacts the same. The human path is `deployment-signoff.yml` + (`workflow_dispatch`): it mints the release App token and posts the verdict + for an RC's Deployment, the in-CI counterpart of the local `mark-deployment.sh` + helper. Both post **as the App** so `deployment-advance` re-triggers; manual + mode needs the cluster auto-poller suspended so it does not claim the + Deployment first. +6. `deployment-advance.yml` reacts to `deployment_status`: on `success`, + fast-forwards `release-ready` (which refreshes the release PR); on `failure`, + the frontier stays put and the team fixes forward on `dev`. + +One platform primitive (Deployments) carries the bridge in both directions — +preferred over a raw `repository_dispatch` precisely for the native visibility +and protection rules. + +### Testability: never wait days to test the pipeline + +The deployment gate is days-long in production, but *duration must never be the +thing under test*. Two properties are **built in** so the whole pipeline is +testable in seconds/minutes: + +1. **Duration is a parameter, not a constant.** The deployment Argo + `WorkflowTemplate` takes a `duration` (and sync-target/chain) input. + Production = "days on mainnet"; a test run = "~10 minutes on regtest/a short + chain", evaluating the *same* pass/fail criteria over a shorter window. The + gate's logic (deploy → observe → evaluate metrics → verdict) is identical; + only the window shrinks. So the gate's behaviour is validated without the + endurance wait — and the genuine endurance property is a separate, infra-side + concern with its own short-window checks. + +2. **The pass/fail signal is injectable.** Because the bridge is a GitHub + Deployment → `deployment_status` callback, the *pipeline's reaction* is + testable completely independently of any real deployment run: create a GitHub + Deployment for an RC commit and POST `deployment_status = success` (or + `failure`) by hand. That fires the `release-ready` advance (step 6) — proving + the `rc → release-ready` promotion + the release-PR refresh + blessing **with + no deployment run at all**. The helper `tools/scripts/mark-deployment.sh` + wraps this one `gh api` call. + +Corollary for the reacting GH Action (step 6): it must key off the real +`deployment_status` event/payload only — no hidden assumption that a run +actually happened — so a simulated status is indistinguishable from a real one. +This keeps the mechanics test path (dispatch + simulated status) and the +endurance test path (short-duration real run) fully decoupled. + +## Ownership split (two repos) + +The bridge is the only contract between the repos, so responsibilities divide +cleanly: + +- **`zaino` repo:** `relman`, the GitHub Actions workflows, branch/PR policy + (rulesets, CODEOWNERS), and Deployment *creation* + `deployment_status` + *reaction*. +- **`devops` repo:** the deployment Argo `WorkflowTemplate`, the Argo Events + eventsource/sensor, the `deployment` environment, and the metrics-threshold + evaluation. + +The contract is a **Deployment event schema** (commit sha, image ref, cycle id) +and the `deployment_status` callback. Either side can evolve independently as +long as that schema holds. + +## Trust model + +One place for "what credentials exist, who can reach them, and what can cause a +release". The workflows carry per-step comments; this section is the inventory. + +### Credentials + +| Secret | What it is | Where it is used | +| --- | --- | --- | +| `RELEASE_APP_ID` / `RELEASE_APP_PRIVATE_KEY` | GitHub App. Its pushes bypass protected-branch rules and trigger downstream workflows (a `GITHUB_TOKEN` push does neither). | `rc-gate`, `blessing`, `deployment-advance`, `changeset-rename`, `backport-sentinel`, `release-pr-body` | +| `CARGO_REGISTRY_TOKEN` | Stored long-lived crates.io publish token for every publishable crate. | `blessing` only (pre-flight check + publish) | +| `DISPATCH_APP_ID` / `DISPATCH_APP_PRIVATE_KEY` | Pre-existing App for cross-repo test dispatch. Not part of this pipeline. | `trigger-integration-tests` | + +Open item: replace the stored `CARGO_REGISTRY_TOKEN` with crates.io Trusted +Publishing (per-run OIDC federation, no stored credential) — under evaluation +in the PR discussion. If the stored token stays, the reason gets recorded here. + +### Exposure + +Workflows that mint an App token on `pull_request` (`changeset-rename`) run +the PR branch's copy of the workflow file with secrets available. A same-repo +PR can therefore modify the workflow and reach the App key. This is the +trusted-writer posture: write access to this repo means release-credential +access. Fork PRs receive no secrets. `changeset-check` is the one ungated +workflow and holds no credentials — it only reads the diff. + +### What can cause a release to advance + +Every path below is inert until the repo variable `RELMAN_PIPELINE_ACTIVE` +is `true`; `RELMAN_PUBLISH_DRY_RUN` additionally downgrades publishing to +advisory. + +1. **Merge of the release PR (`release-ready` → `stable`)** → `blessing` + publishes to crates.io, tags, cuts the GitHub Release. Blessing checks + that provenance (the pushed commit is the merge of a PR from + `release-ready`) and that the pusher is not its own App; any other push + to `stable` is carried back by the backport sentinel but never released. `stable` is protected; the release App bypasses the + protection by design. +2. **`rc-gate` nightly cron** advances the rc frontier when the gate condition + holds. Its `workflow_dispatch` input `force=true` bypasses the gate and is + available to any write-access user; until the nightly green precondition is + wired, `force` is the only advance path (flagged in the workflow header as + the draft escape hatch — do not activate the pipeline before closing it). +3. **Any `deployment_status: success` event** on a gated Deployment → + `deployment-advance` moves `release-ready`. The event's authenticity is not + verified beyond repo write access to post it. + +## Dependency: deployment pass/fail needs metrics + +"Did the deployment gate pass" is a metrics question — sync completed, no crash, performance +within bounds — so the Argo Workflow queries **Prometheus** for its verdict. +This ties the `release`-gate to the observability work (`feature/prometheus-metrics`). +Until those metrics and thresholds exist, the deployment gate can run in an **advisory +/ manual-attestation** mode: the deployment happens and is observed, but the +`release-ready` advance is a human call recorded on the dashboard, not an +automated `deployment_status` gate. + +## Buildability & sequencing + +The **changeset → version → changelog → PR → publish machinery is "GitHub +Actions + `relman`"** — no cluster required. That is the bulk of the work and is +buildable now (slices 1–4 in the build plan). The **deployment gate is the +infra-heavy, separable tail**: it needs the Argo Workflow + Events + Deployments +bridge + metrics criteria, lives mostly in `devops`, and the rest of the +pipeline functions without it — the deployment gate simply stays manual attestation until the +bridge lands. diff --git a/docs/release/pipeline.md b/docs/release/pipeline.md new file mode 100644 index 00000000..877fca21 --- /dev/null +++ b/docs/release/pipeline.md @@ -0,0 +1,1164 @@ +# Zaino Release Pipeline + +## Status + +Authoritative statement of Zaino's branching, gating, versioning, changelog, +and release policy. **Supersedes [zingolabs ADR 003](#relationship-to-adr-003)** +(deprecated). Inherited ADR-003 rules that this document does not change are +reproduced verbatim under [Cross References](#cross-references). + +### Revision history + +- **2026-08-18 — pipeline redesign (this revision).** Reworked the branch + model, gate naming, deployment-gate model, release identity/tagging, hotfix protocol, + and the PR chain. The prior revision (below) described a single advancing + `rc` branch, "tier 1/2/3" gates, version-named RC branches, and left the + hotfix protocol as an open question. This revision replaces all of that. The + reasoning behind each change is recorded inline and collected in + [Design Rationale & Gotchas](#design-rationale--gotchas) so the *why* + travels with the *what*. +- **(prior) — periodic release flow.** Resolved ADR 003's deferred cadence and + RC-validation TODOs. Superseded; kept as + [ADR-0015](../adr/0015-periodic-release-flow.md). + +## Framing Principle + +> **Humans land code and changesets. CI derives everything else.** +> +> A contributor's job is to land a change — on `dev` via a normal PR, or as a +> hotfix on `rc` — and to accompany it with a **changeset** describing what it +> changes and at what semver level. From there the machinery is deterministic: +> CI aggregates changesets, derives per-crate version numbers, generates +> changelogs, advances gate frontiers, cuts prerelease tags, and keeps the +> release PR current. **No human ever writes a version number**, and no version +> is ever *estimated* — every version shown is a pure, exact function of the +> changesets accumulated since the last release, re-evaluated on demand. + +Everything below serves that principle. Where a rule exists, it exists to keep +the derivation deterministic and to make desync visible before it becomes a +released bug. + +## Context + +Zaino is developed on a `dev` branch. Features are PRed against `dev`. Anything +that lands on `dev` is effectively scheduled for release, in the order it +landed. We do not cherry-pick from `dev` to cut releases — a release is always +a **prefix** of `dev`'s history (the hotfix path, below, is the sole, contained +exception). + +There are 23 publishable crates (`zainod`, `zaino-serve`, `zaino-state`, +`zaino-proto`, `zaino-common`, `zaino-primitives`, `zaino-address`, +`zaino-source`, `zaino-source-macros`, `zaino-rpc`, `zaino-convert-zebra`, +`zaino-source-zebra-rpc`, `zaino-source-zebra-readstate`, `zaino-source-zebra`, +`zaino-consensus`, `zaino-mempool`, `zaino-mempool-service`, `zaino-status`, +`zaino-encoding`, `zaino-chain-head`, `zaino-chain-head-service`, +`zaino-chain-store`, `zaino-chain-store-zainodb`) and 3 internal-only +(`e2e`, `clientless`, `zaino-testutils`). Each public crate is versioned and +released **independently**. The authoritative, machine-read list of governed +targets is [`relman.toml`](../../../relman.toml) at the repo root; this prose +list mirrors it. + +> Some worked examples below predate ADR-0008 (which deleted `zaino-fetch` and +> added the source stack) and name the old 6-crate set. The release +> *mechanism* is crate-count-agnostic; only the illustrative tables are stale. + +### Relationship to ADR 003 + +[Zingolabs ADR 003](https://github.com/zingolabs/zingo-adrs/blob/dev/ADR%20003-Zaino%20Branching%2C%20Versioning%2C%20Documentation%2C%20Public%20Interfaces%2C%20and%20Release%20Strategy.md) +previously stated Zaino's branching, versioning, changelog, public-interface, +and release policy at the level of the broader zingolabs organization. That ADR +explicitly deferred two items: a fixed release cadence ("A stable release +schedule should be set in a later ADR") and the process for creating and +validating release candidates (a TODO in its "Release steps" and an entry in +its "Actions" list). This document resolves both, and revises the branch and +gate model beyond what ADR 003 described. + +**Governance principle**: a decision record versioned alongside the code it +governs is authoritative over a decision record held in a separate, generic +repository. Release policy, branching rules, and public-interface governance +are only meaningful relative to a specific state of the code; divorcing them +from the `Cargo.toml`, `CODEOWNERS`, and crate graph they constrain makes the +policy impossible to evolve coherently (a change to the governed public-item +list in one repo has no way to land atomically with the code change it +describes in another). This ADR therefore **supersedes ADR 003** as the +authoritative statement of Zaino's branching, versioning, public-interface, +changelog, and release policy. ADR 003 is **deprecated**; the text this +document inherits from it is reproduced verbatim under [Cross +References](#cross-references) with per-section back-references to the +original. Future changes to any of these rules should be made here, not in +zingo-adrs. + +## Two Axes: Test Taxonomy vs. Gates + +The most common source of confusion in release discussions is conflating two +independent things: + +- **Test taxonomy** — what *kind* of test a test is, divided by intrinsic + properties: compute cost, infrastructure required, duration, and where the + test is defined. This axis is stable and physical. +- **Gates** — *promotion checkpoints* in the pipeline. A gate divides by a + policy question: *at this checkpoint, which tests is it worth running, given + how often the checkpoint fires and how expensive a defect that slips past it + would be?* + +These do **not** map one-to-one. A single kind of test (notably e2e) can run at +more than one gate, in different subsets. Keeping the axes separate is what lets +us name gates for *what they certify* rather than for *which tests they happen +to run* — because the test membership is allowed to change without renaming the +gate. + +### Test taxonomy + +Divided by cost / infra / duration / definition-location: + +| Type | Needs external services? | Duration | Defined | +| --------------- | ------------------------ | ------------- | -------------------------------- | +| **unit** | no | ms–s | within a single module/crate | +| **integration** | no | s | crates integrating, in-repo | +| **e2e** | yes (validator, wallets) | ~minutes–tens | full stack, orchestrated in-repo | +| **deployment** | yes (live chains, infra) | days | long deployments on a cluster | +| **manual** | human | minutes | operator checklist, at blessing | + +> **Project-jargon note (inherited):** the current codebase historically calls +> "unit tests" everything that needs no external services (i.e. unit + +> integration above) and "integration tests" what this table calls e2e. This +> document uses the taxonomy above; where CI wiring still uses the old words, +> the [named suites](#named-suites) indirection insulates the policy from the +> naming. + +### Named suites + +A gate does not hard-code a list of tests. It runs a **named suite**, and the +suite's *membership* is defined separately, in one manifest, decoupled from the +gate. The gate is the stable concept ("the `dev`-gate suite"); the contents are +a swappable definition. + +- `dev-gate` suite — unit + integration + a **bounded, non-flaky e2e smoke** +- `rc-gate` suite — dev-gate **+** the **full** e2e / live-crate suite +- `release-gate` suite — the live-chain deployment soak (not a nextest suite) +- `bless` checklist — the **manual** suite (human attestation at release time) + +The precise rule that sorts a test into a suite, and the manifest that records +it, are in § "Suite membership" below. + +Two properties this buys us: + +1. **Evolvable without churn.** We can move a test between suites, or tune the + smoke subset, by editing the manifest — no policy document changes, no gate + renames, nobody re-anchored to "the gate *is* these exact tests." +2. **Cumulative by construction.** Each gate's suite is a superset of the prior + gate's guarantees (a commit reaching the `rc-gate` has already cleared the + `dev-gate`). Suites name the *additional* cost admitted at each step, not the + total. + +The suites are realized as `cargo nextest` filtersets plus a deployment-launch +descriptor. The **sorting criterion** and the **manifest schema** that fix +membership are specified below (§ "Suite membership"); the concrete selector +strings are filled in when the categorization pass runs against the tree. This +document fixes the **indirection and the rule**, not the final per-test list. + +**How a gate reads its suite (the indirection, wired).** A gate never names a +workflow or a test mode. It requires a **named signal** — a check-run or commit +status whose context is the suite name (e.g. `rc-gate`, configurable per repo) — +to be `success` on the commit it is admitting. *Whatever* produces that signal +is the swappable membership: a `nextest` workflow, an external runner, or a human +posting a manual attestation all satisfy the same gate identically. Concretely: +`rc-gate` checks for a green `rc-gate` check/status on `dev` HEAD before +advancing; the deployment gate runs a **named WorkflowTemplate** whose content is +the suite. So the gate↔flow-point mapping is fixed in code while the gate↔suite +mapping is pure configuration — the two concerns never touch. + +**The publishers, wired.** The signal producers exist; the gate still names none +of them: + +- `rc-gate` signal — any producer that posts a `success` check-run/commit-status + with context `vars.RELMAN_RC_GATE_CHECK` (default `rc-gate`) on the tested + commit. `CI - Nightly` (`ci-nightly.yaml`) carries a reference producer: it runs + the full suite (the complete `nextest` run plus check/fmt/clippy/doc/whitespace/ + cargo-hack) and, once every job is green, its `publish-rc-gate-signal` job posts + that status. **Caveat (current):** those suite jobs run on self-hosted + `arc-sh-runners`, of which there are none right now — so the publish job's + `needs` are unmet and no signal is produced (the gate simply won't advance + without `force`; no false green). The anticipated real producer is a **cluster + job driven by `ztest`** that runs the suite and posts the same status; when it + lands it becomes the publisher with no change to the gate. `GITHUB_TOKEN` + suffices for whichever producer runs inside Actions — the gate only reads. +- `release-gate` (deployment) signal — normally the cluster deployment gate posts + a `deployment_status` back (poller → live-chain WorkflowTemplate). The manual + path is `deployment-signoff.yml`: a human dispatches `success`/`failure` for an + RC's Deployment. Both post **as the release App** (not `GITHUB_TOKEN`) so + `deployment-advance.yml` re-triggers. Manual mode requires the auto-poller + suspended so it does not claim the Deployment first. + +### Suite membership + +Which test lands in which suite is decided **here**, never by the runner. The +runner — `ztest`, a restored self-hosted fleet, or plain CI — is a *scheduler and +reporter*: it resolves a gate name to a selection this repo defines, executes it, +and posts the named signal (§ "How a gate reads its suite"). A "gate-aware" +runner is simply one that reads the manifest below; that awareness grants it no +authority over membership. This is what lets `ztest` give a "simple standard +answer" (`rc-gate: green`) without owning any policy. + +**The sorting criterion.** The axis is **fidelity vs. cost**. Each suite admits +the next tier of fidelity and pays the next tier of cost; membership is cumulative +(§ above), so a test belongs to the *cheapest* suite whose fidelity it needs and +is never duplicated into a richer one. The `dev-gate`/`rc-gate` split is governed +not by "does it touch a validator" but by the **two hard constraints on the +`dev`-gate** (§ "The `dev`-gate"): a fixed **wall-clock ceiling** (~10–15 min, on +every contributor's critical path) and **non-flakiness** (a flaky *blocking* check +is a worse tax than a slow one). A test that violates either drops to `rc-gate`. + +| Suite | Admits at | Rule | Cost | +| --- | --- | --- | --- | +| `dev-gate` | every PR push | Fits the wall-clock ceiling **and** is non-flaky. Hermetic unit + integration **plus** a curated e2e *smoke* subset (may spin a validator, but stays cheap and deterministic). *"Is the code internally correct + does a cheap real-chain sanity check pass?"* | ≤ ~10–15 min | +| `rc-gate` | dev→rc, nightly | dev-gate **+** the **full** e2e / live-crate suite — everything too slow or flake-budgeted for pre-merge. *"Does it work against real chain software, exhaustively?"* | tens of minutes | +| `release-gate` | rc→release-ready | **not a nextest suite** — a live-chain soak (serve-zaino warm-start, wallet fixtures, tip/sync validation). *"Does it survive a real deployment?"* | minutes–days (dial) | +| `bless` | release | human attestation checklist. | — | + +The rule resolves today's inherited `CI - PR` exclusions explicitly, and they +are not one kind of thing: + +- `clientless::chain_cache` — excluded because it **hangs** (zingolabs/zaino#1312). + That is **debt to fix, tracked as disabled**, never relabelled up a tier to + hide the hang. +- the `e2e::*` group — excluded for **CI capacity** (zingolabs/zaino#1308). Under + the rule that is simply the correct `rc-gate` placement (validator-heavy, over + the pre-merge budget); the one small regtest binary `e2e::compact_block_wire` + is pulled back as a smoke. +- `clientless::json_server`, `e2e::devtool_zcashd` — `zcashd_support`-feature-gated. + A **separate axis**, not a dev/rc cost tier: the default `--no-default-features` + build holds zero of their tests. + +Two subtleties the first cut turns on. (1) The ceiling counts **fixture/snapshot +provisioning**, not just execution: a test that needs a multi-GB testnet snapshot +is over the pre-merge budget even when it runs fast once loaded — and where it +*skips* without the snapshot it gives no pre-merge signal anyway. (2) The testnet +caches are **local snapshot dirs**, so those tests are deterministic, *not* +network-flaky — they are `rc`-tier on provisioning cost, not on flakiness. + +**The manifest (contract 1 — committed, first cut).** One declarative artifact +maps each nextest-realized gate to a selection. It is the single source of truth +the runner *and* CI read; nothing else enumerates tests. Lives at +`.release/gate-suites.toml`, sibling to `.release/consumed-ledger.toml`; no +consumer reads it yet (wired when the runner lands), but the classification is +now data rather than scattered YAML. `release-gate` is intentionally absent — not +a nextest suite; it answers via `deployment_status` from the devops repo. + +```toml +[dev-gate] +filterset = "(!package(clientless) & !package(e2e)) | binary_id(=clientless::test_vectors) | binary_id(=clientless::validator_heights) | binary_id(=clientless::compact_block_consistency) | binary_id(=e2e::compact_block_wire)" + +[rc-gate] +extends = "dev-gate" # cumulative: runs dev-gate's selection too +filterset = "(package(clientless) | package(e2e)) & !binary_id(=clientless::chain_cache)" # full live suite minus the hang (#1312) +``` + +The first-cut classification of the 13 live-crate binaries (the file's header +carries the full rationale): + +| Binary | Tier | Why | +| --- | --- | --- | +| `clientless::test_vectors` | **dev** | ~2 tests, no validator launch | +| `clientless::validator_heights` | **dev** | ~5 tests, regtest mining | +| `clientless::compact_block_consistency` | **dev** | ~13 tests, regtest mining | +| `e2e::compact_block_wire` | **dev** | ~7 tests, regtest — pulled from the capacity-excluded e2e group | +| `clientless::fetch_service` | **rc** | ~35 tests — over the smoke budget | +| `clientless::state_service` | **rc** | ~75 tests; cached-testnet + regtest mix (needs snapshot) | +| `clientless::the_pub_testnet_ironwood_boundary` | **rc** | needs a testnet snapshot; skips without one | +| `e2e::test_vectors` | **rc** | heavy zebra fixtures | +| `e2e::ironwood_activation` | **rc** | ~22 tests, validator-heavy | +| `e2e::devtool` | **rc** | ~170 tests | +| `clientless::chain_cache` | **disabled** | hangs → #1312; excluded both tiers until fixed | +| `clientless::json_server` | *feature axis* | `zcashd_support`-gated; absent from the default build | +| `e2e::devtool_zcashd` | *feature axis* | `zcashd_support`-gated; absent from the default build | + +Deltas from today's `CI - PR` set: `state_service`, `fetch_service`, +`the_pub_testnet_ironwood_boundary` move **out** of pre-merge (size / snapshot +dependency); `e2e::compact_block_wire` moves **in**. A finer within-binary split +(keeping the regtest subset of `state_service`/`fetch_service` as a smoke) is +deferred to per-test filtersets once real wall-clock data exists — the manifest +edit is one line when it does. That one-line-edit property is the point: moving a +test between tiers needs no gate rename or policy-doc churn, and a gate-aware +runner resolves `rc-gate` by reading `extends` + `filterset` from here, so +"`ztest` knows the gates" costs nothing beyond parsing this file. + +**The answer envelope (contract 3 — a documented target, NOT built yet).** The +gate mechanism needs only the boolean signal (§ "How a gate reads its suite"). +*Richer* reporting — for the release PR and relman's changelog/audit — would want +a producer-agnostic shape so `ztest` and the devops soak repo could both feed it. +When (and only when) a second live producer exists, that shape is a thin +**ref-carrying envelope**, never a per-test dump: + +```json +{ + "commit": "", + "gate": "rc-gate", + "verdict": "success | failure", + "producer": "ztest | ci-nightly | deployment-soak", + "detail_ref": "", + "at": "" +} +``` + +Per **refs over content**, `detail_ref` is a pointer the consumer follows on +demand; relman never ingests per-test rows. This is deliberately *unbuilt*: the +deployment gate already has its own answer schema (GitHub `deployment_status`), +and unifying two producers before both exist is speculative. The envelope is +recorded here as the boundary so it is not re-invented ad hoc — implement it at +the first heterogeneous-producer aggregation, not before. + +## Gates + +Three gates, each named for the branch it admits a commit to. A gate runs a +cumulative, cost-bounded suite; the cost budget rises as the gate fires less +often. + +| Gate | Grants entry to | Suite | Fires | Budget | +| ---------------- | ---------------- | -------------------- | ------------------------------ | --------------- | +| **`dev`-gate** | `dev` | `dev-gate` suite | pre-merge, **every push** | minutes | +| **`rc`-gate** | `rc` | `rc-gate` suite | **nightly**, on `dev` HEAD | tens of minutes | +| **`release`-gate** | `release-ready` | `release-gate` suite | **continuous**, per RC commit | days | + +The gate a commit has cleared is read directly off **which branch it is on** — +the branches *are* the high-water marks (see [Branch Model](#branch-model)). +There is no separate marker ref to maintain: `dev` = passed `dev`-gate, +`rc` = passed `rc`-gate, `release-ready` = passed `release`-gate. + +### The `dev`-gate (pre-merge) + +Runs on every PR, pre-merge, and is a required check to merge into `dev`. +Because it is a merge requirement, **`dev` itself is the gate's marker**: every +commit on `dev` has cleared it by construction, so no extra branch or ref is +needed for this gate. + +Contents: unit + integration + a **fast e2e smoke** subset. Splitting a bounded +e2e smoke out of the full e2e suite and running it here is deliberate — see +[Gotcha: e2e straddles two gates](#gotcha-e2e-is-not-one-bucket). Two hard +constraints govern what may live in this suite: + +- **Wall-clock ceiling.** This gate sits on every contributor's critical path + on every push. Its total runtime must stay under a fixed budget (target: + ≤ the unit+integration time, or a fixed ceiling on the order of 10–15 + minutes). A test that cannot fit does not belong here; it waits for the + `rc`-gate. +- **Non-flaky.** A flaky *blocking* pre-merge check is a worse tax than a slow + one: it erodes trust and trains people to re-run blindly. Flaky candidates + belong in the nightly `rc`-gate, not here. + +### The `rc`-gate (nightly) + +Runs nightly against `dev` HEAD as a **batch**: whatever is on HEAD at run time +is tested as a unit. On pass, HEAD is admitted to the `rc` branch (see +[Promotion](#promotion-flow)) and a deployment run is launched. On fail, the `rc` +frontier does not advance; the team fixes forward on `dev` and the next nightly +attempt tests the new HEAD. + +Contents: the full e2e suite. We deliberately do **not** bisect between the last +`rc` and HEAD to find the highest green commit — that adds machinery for +marginal granularity. Batch-and-wait is simpler and preferred to start. (Manual +override exists for emergencies.) + +### The `release`-gate (continuous deployment) + +Runs the deployment suite — days-long, on live chains — against **`rc` +commits**. The deployment gate is **continuous and per-commit-pinned**: when +the `rc`-gate advances `rc` to a new commit, a deployment run pinned to *that +exact commit* is launched immediately. The frontier keeps moving; each +deployment target is frozen. + +- Infra supports 3–4 parallel deployment slots plus a queue. +- **Coalescing rule:** when a slot frees, run the deployment gate on the + *latest* available `rc` commit, skipping any it leapfrogged. Never spend a + slot on a commit that is already superseded and has not been through the + deployment gate. +- On pass, the tested commit is admitted to `release-ready`. On fail, the fix + goes forward on `dev` (or as a hotfix on `rc`) and a later commit re-runs the + deployment gate. + +The commit that passed the deployment gate is what "cleared all gates" means. +There is no further gate at blessing — see +[Blessing](#blessing-the-only-human-decision). + +## Branch Model + +Four branches, each the high-water mark of the gate that admits to it: + +``` +dev ──► rc ──► release-ready ──► stable + │ │ │ │ + │ passed rc-gate │ blessed (human) + │ (in deployment) passed published release +passed dev-gate release-gate +(everything here) (fully gated, + always blessable) +``` + +- **`dev`** — linear queue of all accepted work; passed the `dev`-gate. Only + moves forward (fast-forward merges). +- **`rc`** — commits that passed the `rc`-gate and are **under deployment + testing**. This is what "release candidate" conventionally means: a build + being validated. Carries the `cycle-*-rc.N` prerelease tags and is the + **landing zone for hotfixes**. +- **`release-ready`** — commits that passed the `release`-gate (deployment). + Always fully gated, therefore **always safe to bless**. This is the head of + the release PR into `stable`. +- **`stable`** (a.k.a. `main`) — the latest published release. + +**Why two intermediate branches, not one.** `rc` advances on *nightly* pass so +the deployment gate can start immediately — which means `rc`'s HEAD is routinely +*freshly untested by the deployment gate*. If the release PR pointed at `rc`, +blessing could ship code that never cleared the deployment gate. Graduating +deployment-passed commits to a second branch, `release-ready`, guarantees the +release PR's head is *always* fully gated, so blessing is a pure human timing +decision, never a gamble. `rc → release-ready` is a CI fast-forward on +deployment-pass. + +**Branches carry no version in their name — on purpose.** The release version +is *derived* from changesets and is not knowable until blessing (more changesets +may land first). A version-named branch (`rc/0.8.0`) would go stale the moment a +larger bump accrued, forcing a rename. All four branches are +**version-agnostic**; the derived version lives only in the release PR and +prerelease notes. See [Gotcha: version-agnostic +everything](#gotcha-nothing-renameable-carries-a-version). + +**Branches only move forward.** After a blessing, `release-ready == stable`; +the next cycle's frontier advances forward from there. This preserves a clean, +append-only lineage that the promotion rules and the hotfix backport depend on. + +## Promotion Flow + +`dev` is a linear queue that only moves forward. New work keeps landing on +`dev` regardless of gate status; a gate failure blocks **frontier advancement**, +not development. + +``` + nightly rc-gate continuous release-gate +dev HEAD ───────────────► rc ──(pinned deployment, 3–4 slots)──► release-ready ──► stable + (dev-gate, (deploying, (deployment-passed, (blessed) + every push) cycle-*-rc.N tags) always blessable) +``` + +At any moment the three branch tips answer "what is the newest commit that +cleared gate N?" — `dev` for the `dev`-gate, `rc` for the `rc`-gate, +`release-ready` for the `release`-gate. These are the **visible markers** the +pipeline is built to expose. + +### Gate failures are fixed forward + +When a gate fails, the frontier does not advance; the fix lands on `dev` as a +normal PR (or, when justified, as a [hotfix](#hotfix-protocol) on `rc`), and a +subsequent gate attempt tests the new state. The pipeline never reorders or +reverts shared history. + +- **Cost:** a gate-blocking commit delays advancement until a fix lands and the + next attempt succeeds. +- **Mitigation:** nightly `rc`-gate attempts surface failures within ~24h, + while author context is fresh. +- **Caveat:** fix-forward targets HEAD, which may have accumulated other work. + If that work adds *another* problem, the fix must account for both. This is + the standing pressure to keep gate feedback tight — the less time between a + bad commit and its detection, the less unrelated work piles on top. + +### `dev` is the release queue + +Everything on `dev` is meant for release; the machinery *will* try to ship it. +Therefore nothing may land on `dev` that we *already know* isn't releasable. + +| Work type | Belongs on `dev`? | Risk | +| ------------------------ | ----------------- | ---------------------- | +| Bug fix | Yes | None | +| Completed feature | Yes | Might fail higher gate | +| Feature-gated incomplete | Yes | Inert in default build | +| **Ungated incomplete** | **No** | **Poisons the queue** | +| Refactor (internal) | Yes | None | +| Refactor (public API) | Yes | Downstream breakage | +| CI/infra (non-src) | Yes | Invisible to release | + +**Policy:** ungated incomplete work must never land on `dev`. All incomplete +features must be behind a feature gate. An ungated broken commit doesn't just +risk itself — it **stalls the shared pipeline**: the nightly `rc`-gate fails and +*no one's* frontier advances until it's fixed forward. Every defect caught at +the `dev`-gate (including by the fast e2e smoke) is a queue-stall never paid. + +### Crate modularity reduces blast radius + +The more crates depend on abstractions (traits) rather than concrete types from +other crates, the more independent PRs are: implementation changes can't +silently break another crate, contract changes are explicit and small, +gate-failing commits are more likely revertable in isolation (kept as an +emergency option, though the flow doesn't rely on reverts), and PRs conflict +less. + +## The PR Chain + +Standing PRs are used deliberately as **live desync sentinels**: a PR forces its +head branch to stay mergeable into its base, so GitHub itself surfaces — and +demands resolution of — any fix that landed on one side but not the other. + +### Forward: the Release PR (`release-ready → stable`) + +Long-lived. CI keeps its **description** current with the derived per-crate +version table and the aggregated changelog — i.e. *"merge this and here is +exactly what ships, right now."* Blessing is merging it (see below). + +- **Detects:** an emergency hotfix pushed straight to `stable` moves the base + ahead of the head; GitHub flags the PR out-of-date, forcing the fix back into + `release-ready` / `rc`. +- **Update discipline:** keep it current by **merge**, never by rebase (see the + protected-branch rule below). The default "Update branch" (merge) is a + non-force operation and is safe. + +### Reverse: the Backport Sentinel (aux branch → `dev`) + +Guarantees no commit that reached `stable` is ever stranded outside `dev` (a +released hotfix, or the release merge itself). Modeled event-driven: a bot +watches for `stable \ dev ≠ ∅` and, when non-empty, prepares the backport and +opens a PR into `dev` from a disposable `sync/stable-to-dev` branch cut at +stable's tip. + +**The PR carries only already-vetted content** (the blessing's release commit, or +a hotfix that already cleared the `rc`- and deployment-gates), so it needs no +*review*. It exists for two reasons: to run the `dev`-gate on the **merged +result** — catching a *semantic* conflict a textually-clean merge would hide — and +to be a **visible desync signal** exactly when one is needed. + +**Merging it — human by default, auto-merge opt-in.** Whichever path, the merge is +a **merge commit**, never squash/rebase — only a true merge makes `stable \ dev` +empty and stops the sentinel re-firing. + +- **Default (`RELMAN_BACKPORT_AUTOMERGE` unset/false): a maintainer merges it.** + The sentinel still guarantees the PR exists and runs the gate on the merged + result; a human clicks merge. The conservative default — the automation opens + and signals, a person commits the backport into `dev`. +- **Opt-in (`RELMAN_BACKPORT_AUTOMERGE=true`): it self-dissolves.** The bot enables + auto-merge, and the step is idempotent + self-healing (re-asserts auto-merge on + an already-open PR, past GitHub's post-create mergeability race, without touching + the branch). Then **clean + gate-green** → merges instantly, no human touch, + `dev` catches up, the sentinel goes quiet; **textual conflict *or* red gate** → + the PR stays open as the signal a human must act on. Enabling this later — after + watching a few backports land by hand — needs no code change: the next `stable` + push re-asserts auto-merge on the standing PR. + +Either way a no-conflict backport is *not* direct-pushed past the gate — it goes +through the PR, which also avoids putting the App on `dev`'s protection-bypass +list. + +**Why not a direct `stable → dev` PR:** keeping such a PR mergeable would mean +rebasing/"update branch"-ing its head — which is `stable`, a protected branch — +and that force-pushes a protected branch, which is forbidden. So the reverse +sync goes through a **disposable auxiliary branch cut from `stable`**: resolve +conflicts there (force-push it freely — it's throwaway), then PR +`sync/stable→dev → dev`. From `dev`'s point of view it's an ordinary feature +PR. Trivial (clean cherry-pick) cases the bot prepares fully; only genuine +conflicts need a human on the aux branch. + +### Rule: a protected branch is never a PR *head* + +Generalizing the above: a protected branch (`stable`, and a lightly-protected +`release-ready`) may be a **merge target** (things merge into it) and a +**branch source** (you cut from it), but **never a PR head you must keep +mergeable** — because the "Update branch (rebase)" path force-pushes the head. +Any reconciliation that would mutate a protected branch instead happens on a +disposable aux branch. Corollary: **update-by-merge, never update-by-rebase**, +on any protected-ish head. + +The intra-pipeline advances — `dev → rc` and `rc → release-ready` — are **CI +fast-forwards on gate-pass**, not human PRs. They can be surfaced as +visibility-only PRs if the pipeline backlog is wanted in the PR list, but the +two sentinels above are the ones doing structural work. + +## Release Identity: Versions, Tags, Changesets + +Nothing that can only be *renamed-or-recreated* (a branch, a ref) carries a +version. Version identity is **late-bound** and lives in three layers, all +**derived**, never estimated. + +### Changesets + +Every PR to `dev` (and every hotfix on `rc`) must include a **changeset file** +under `.changesets/`, declaring which crates it affects, at what *semantic* +level (`kind`), with a description. The full field contract, filename scheme, +aggregation, and enforcement live in [Changeset +Format](./changeset-format.md); in brief: + +```toml +[[changes]] +crate = "zaino-state" +kind = "feature" # breaking | feature | fix | internal — CI derives the semver bump +description = "New parallel sync mode" + +[[changes]] +crate = "zainod" +kind = "feature" +description = "Expose parallel sync mode in CLI config" +``` + +- A single PR may declare changes to multiple crates. +- **Enforcement:** CI rejects PRs that touch crate source without a changeset. +- **Per-public-change entries:** when a PR changes a [governed public + interface](#governed-public-interfaces-inherited-from-adr-003-5), each such + change is its own `[[changes]]` entry — the aggregated changeset set is the + source from which per-crate and workspace changelogs are generated, so every + user-visible change must be listed individually. `description` may be + multiline and should read as a standalone changelog line (operator-facing, + plain language, no invented jargon). Internal-only changes still need an + entry (typically `patch`) but may be collapsed into one describing the net + effect. This implements the recording requirement of ADR 003 §4 (see [Cross + References: Changelog policy](#changelog-policy-inherited-from-adr-003-4)). + +### Derivation is monotonic within a cycle + +CI aggregates all changesets since the last release, resolving the **highest +bump per crate**. Because changesets only *accumulate* within a cycle (never +removed) and aggregation is highest-wins, the derived target version per crate +is **monotonically non-decreasing** across a cycle: `patch → minor → major`, +never backward. It therefore never flaps — the value shown at any instant is +exact for the current changeset set, and the only thing that changes it is more +changesets landing. + +On merge of the release PR (blessing), the `.changesets/` directory is cleared; +the next cycle starts fresh. + +### Three identity layers + +1. **Cycle (period) tags — drive the deliverables.** A release *cycle* (e.g. + Friday-to-Friday) has an identity independent of any version: + `cycle-` (e.g. `cycle-2026-08-15`). Prerelease builds within the + cycle are tagged `cycle--rc.` and produce prerelease Docker images / + GitHub prereleases. This is the stable human handle for "the Friday release," + and it carries no version — so it can never lie. +2. **Per-crate version tags — crates.io provenance.** At blessing, each crate + that bumped is tagged `-` (e.g. `zaino-state-0.4.0`), one git + point per published `crate@version`. This is standard independent-versioning + provenance and makes "which commit was `zaino-state 0.4.0` cut from?" + answerable. +3. **Derived versions — live only in the PR/notes.** The per-crate resolved + versions appear in the Release PR description and prerelease notes, updated + by CI, and in *no tag name*. A tester pulling `cycle-…-rc.2` reads the notes + to learn which crate versions it contains. + +**Docker image:** tagged with the daemon (`zainod`) resolved version (e.g. +`zingodevops/zaino:0.5.0`) plus the cycle handle — not a repo-wide `X.Y.Z`, +which is meaningless once crates version independently. (Retains ADR 003 §6's +requirement that images be version-tagged and SHOULD also carry the commit SHA.) + +## Blessing: the Only Human Decision + +A long-lived **Release PR** (`release-ready → stable`) is the dashboard. Its +head is always fully gated, so it is always safe to merge. CI keeps it showing +recent deployment status, the derived per-crate version table, and the aggregated +changelog. Sketch: + +``` +## Release Candidate: release-ready @ def456 (cycle-2026-08-15-rc.6) + +All gates passed. Merging promotes this commit to stable. + +## Deployment status +| RC commit | tag | deployment | +| --------- | ------------------- | --------------- | +| abc123 | cycle-…-rc.7 | day 2/3 running | +| def456 | cycle-…-rc.6 | passed | +| 789abc | cycle-…-rc.5 | failed (stall) | + +## Version bumps (derived, since last stable) +| Crate | Current | Next | Changes | +| ----------- | ------- | ----- | ----------------------- | +| zaino-state | 0.1.0 | 0.2.0 | new sync mode, fix #987 | +| zainod | 0.2.0 | 0.3.0 | new sync mode exposed | +``` + +Releases are **periodic** (e.g. weekly). At the end of a cycle a maintainer, if +satisfied, merges the Release PR. That merge is the **blessing** — and it is the +*only* human decision in the pipeline. It is **not a gate**: all three gates +were already cleared by the commit being promoted. "Whatever cleared all gates +by Friday" is exactly whatever is on `release-ready` on Friday; work that only +cleared the deployment gate after the cut simply ships the next cycle — *easy as that*. + +At blessing, CI: finalizes the derived versions, applies the `-X.Y.Z` +and `cycle-` tags, publishes (Docker, GitHub Release, crates.io in +dependency order), **marks the shipped changesets consumed** (stamps each +`consumed_in`, rather than deleting — see +[changeset-format.md § Consume by marking](./changeset-format.md#consume-by-marking-not-erasing)), +and the reverse [backport +sentinel](#reverse-the-backport-sentinel-aux-branch--dev) fires to carry the +release commit — bumps, changelogs, and the consumed marks — back into `dev`. + +## Hotfix Protocol + +The primary flow is fix-forward on `dev`. A **hotfix** is for when a fix is +known, `dev` has diverged enough that landing it there and waiting for a fresh +commit to re-traverse the `rc`- and `release`-gates is too slow, and the fix is +needed in the *current* release line. + +**The protocol (one rule):** + +1. Land the fix **on `rc`**, with its changeset. It carries an `rc`-gate check + like anything entering `rc`, then goes through the **deployment gate** like + any other `rc` commit — hotfixes are not exempt from it. +2. On deployment-pass it graduates to `release-ready` and can be blessed. +3. **Backport to `dev` before `rc`'s next fast-forward advance.** The reverse + [backport sentinel](#reverse-the-backport-sentinel-aux-branch--dev) enforces + this: once the hotfix reaches `stable`, `stable \ dev` is non-empty and the + sentinel demands the backport. + +**Why this dissolves the classic hotfix tangle.** The historical worry was that +a hotfix on `rc` makes `rc` diverge from `dev`, so a later "advance `rc` to a +new `dev` commit" would lose the hotfix. That only bites if `rc` advances by +*replacement* to an arbitrary `dev` commit. Here `rc` only ever +**fast-forwards**, and the single rule "backport before next advance" means the +new frontier already contains the fix — so the advance stays a clean +fast-forward and nothing is lost. Version-agnostic branches make this safe: +there is no version-named branch to reconcile. + +**Nuclear option.** A true emergency may go straight to `stable`. Both +Such a push is **not blessed**: blessing releases only the merge of the +release PR, so the hotfix reaches users through the next cycle after the +sentinel carries it back to `dev`. +sentinels then catch the fallout: the forward Release PR flags `release-ready` +as behind `stable` (forcing the fix into `rc`/`release-ready`), and the reverse +sentinel forces it into `dev`. + +## Implementation + +> The execution-substrate map (GitHub control plane / Argo deployment data plane), the +> `relman` responsibility boundary, and the GitHub↔cluster deployment-gate bridge are +> detailed in [Implementation Architecture](./implementation.md). Summary below. + +**Logic lives in a Rust CLI; CI stays thin.** The changeset parse/aggregate, +semver derivation, transitive-bump computation, format-preserving `Cargo.toml` +edits (via `toml_edit` — never `sed`, per the repo's Rust-native rule), +changelog generation, and release-PR body rendering live in a Rust CLI. CI +workflows call it and do only git/`gh` glue. This keeps the derivation +**unit-testable and locally runnable** (the same commands a maintainer can run +by hand), rather than trapped in untestable shell. + +**A new sibling tool crate, not an extension of `workbench`.** The existing +`tools/workbench` is a deliberately **std-only, no-framework, one-binary-per-file** +dev-tooling crate in its own isolated workspace, doing heavy lifting by shelling +out (`curl`/`tar`/`diff`/`cargo`/`git`) specifically to avoid pulling in Rust +crates. The release CLI needs the opposite — real dependencies (`toml_edit`, +`semver`, `serde`/`toml`) and a subcommand router (`clap`) so `changeset` / +`bump` / `changelog` operations can share the crate-graph logic. Adding those to +`workbench` would break its documented "tiny, never touch the production graph" +contract. Instead, a **new sibling crate under `tools/`** (e.g. `tools/relman`) +**copies workbench's isolation pattern** — its own workspace, `publish = false`, +fmt/clippy/tested in CI — but is a clap-based multi-subcommand binary carrying +the release deps. That isolation is exactly what licenses using those crates +without affecting production or `cargo-deny`. The existing +`check-published-versions` guard stays in `workbench` and is reused as-is from +the publish flow. + +No external release manager (`release-plz`, `cargo-release`, `knope`) is +adopted: our model (version-agnostic branches, cycle tags, continuous deployment, +derived-only versions) diverges enough that config-fighting would cost more than +it saves; ideas may be borrowed, the tool is not. + +**Existing-workflow teardown** (first implementation step, before building the +replacement): + +| Workflow | Verdict | +| -------- | ------- | +| `auto-tag-rc.yml` | **keep until cutover** — derives version from the `rc/` branch name; gated off by `RELMAN_PIPELINE_ACTIVE=true`, delete afterwards | +| `final-tag-on-stable.yml` | **keep until cutover** — same branch-name→version coupling; gated off by `RELMAN_PIPELINE_ACTIVE=true`, replaced by blessing-time tagging from changesets | +| `release.yaml` | **reworked** — also fires on the `zainod-X.Y.Z` provenance tag and takes the image version from it; the GitHub Release job runs only for legacy tags | +| `publish-dry-run.yml` + `check-published-versions` | **kept** — blocking on `stable` pushes, advisory elsewhere (`rc` is version-agnostic); the Rust guard also runs in blessing's pre-flight | +| `ci.yml`, `ci-nightly.yaml` | **rework** into the `dev`-gate and `rc`-gate suite runners | +| `compute-tag.yml`, `build-n-push-ci-image.yaml`, `trigger-integration-tests.yml`, `shellcheck.yaml` | **keep** — orthogonal to release versioning | + +## Open Questions (deferred to the build slice) + +The design above is settled. These *mechanism* details are deferred to +implementation and do not block the branch/gate/identity model: + +### Changeset format & generation details (open) + +Exact `.changesets/` TOML schema, the aggregation command's changelog-rendering +rules, and the precise shape of the bot commit's edits. The *how* (below) is +decided; these content specifics are for the build slice. + +### Transitive version bumps + +When crate B bumps and crate A depends on B: if B's bump stays within A's caret +range, A is untouched; if it crosses a compatibility boundary, A's `Cargo.toml` +requirement must update — a source change forcing at least a patch bump on A. +Under Cargo caret semantics the boundaries are: 0.x → any minor is breaking +(0.1→0.2); 1.x+ → only major is breaking; 0.x→1.0 is a boundary. Under current +0.x versioning, every dependency minor bump is a boundary crossing, so +transitive bumps are frequent; reaching 1.0 would reduce the noise. +**Direction (committed):** CI derives transitive bumps **mechanically** during +changeset aggregation — never hand-tracked by PR authors. The exact algorithm +is deferred. + +### Dependency version requirement syntax + +Tilde (`~1.2.0`) locks to a minor and accepts only patches — tighter than caret +but causes *more* transitive bumps. Trade-off to evaluate once crates stabilize +past 0.x. + +### Version targeting + +Consensus on per-crate independent versioning; the specific 1.0 timing and +whether crates move in lockstep or independently remains to be defined. + +## Design Rationale & Gotchas + +The traps and realizations that shaped this revision, kept so the reasoning +survives the decisions. + +### Gotcha: e2e is not one bucket + +The debate "should e2e run on every feature, or only at RC cut?" dissolves once +you stop treating e2e as atomic. A **fast e2e smoke** subset can be made cheap +and deterministic enough to gate pre-merge (catching cross-service regressions +before they poison the `dev` queue); the **full e2e suite** stays nightly. So +e2e legitimately *straddles* the `dev`-gate and the `rc`-gate. This is why gates +are named for what they certify, not for a test type — "the e2e gate" would be a +category error. (It also restores, more sharply, the fast/full split ADR 003 +originally had and the prior revision collapsed.) + +### Gotcha: nothing renameable carries a version + +Version-naming a branch (`rc/0.8.0`) early-binds the one thing the changeset +system exists to keep late-bound. Since the aggregated bump only grows as +changesets land, a branch named for an early guess goes stale and would need +renaming — messy, and it defeats derivation. Resolution: **all branches and +refs are version-agnostic**; the derived version lives only in the Release PR +and prerelease notes, and is finalized exactly once, at blessing. There is no +"estimated" version anywhere — only a deterministic derivation re-evaluated on +demand. + +### Gotcha: the deployment gate needs a frozen target, but freezing the branch is wrong + +The deployment gate takes days; if its target moved with the frontier, it would +always be testing stale code. But *freezing a branch* for a period blocks the +"deploy whatever passed, ASAP" goal. Resolution: freeze the **deployment run** +(pin it to a commit + `cycle-*-rc.N` tag), not the branch. The frontier keeps +advancing and launching fresh pinned deployment runs (3–4 slots, +coalesce-to-latest); `release-ready` tracks the newest deployment that passed. +Whatever also had time to clear the deployment gate by Friday ships; the rest +waits. + +### Gotcha: a protected branch must never be a PR head + +A standing `stable → dev` sentinel is tempting but unworkable: keeping it +mergeable means "update branch," and the rebase variant force-pushes `stable`, +which protection forbids. Resolution: the reverse sync runs through a +**disposable aux branch cut from `stable`**, PR'd into `dev` like any feature — +which is exactly the manual workaround maintainers already used, now formalized +and bot-assisted. Generalized to the rule: protected branches are merge-targets +and branch-sources, never rebase-targets; **update-by-merge, never rebase**. + +### Gotcha: naming should say what is certified, not what happened + +"Tier 1/2/3" is opaque, and state-past names like "released" are wrong for a +marker that means *releas-able-not-yet-released*. Gates are named for the branch +they **admit to** (`dev`-gate, `rc`-gate, `release`-gate); the branches +themselves double as the gate high-water marks, so no separate marker ref is +needed. `release-ready` says "eligible to ship," not "shipped." + +## Cross References + +This ADR inherits a body of rules from [zingolabs ADR +003](https://github.com/zingolabs/zingo-adrs/blob/dev/ADR%20003-Zaino%20Branching%2C%20Versioning%2C%20Documentation%2C%20Public%20Interfaces%2C%20and%20Release%20Strategy.md). +The inherited text is reproduced here verbatim so that the authoritative +statement of each rule travels with the code it governs. Each subsection +attributes the source section of ADR 003. + +**ADR 003 is deprecated** by this document, per the governance principle in +[Relationship to ADR 003](#relationship-to-adr-003): a repo-bound, +version-bound decision record supersedes a generic cross-repo decision record +on matters specific to this repo. Future changes to any rule below must be +made in this file, not in `zingolabs/zingo-adrs`. + +### Branching and approvals (inherited from ADR 003 §1) + +From [ADR 003 §1, "Branch / development strategy"](https://github.com/zingolabs/zingo-adrs/blob/dev/ADR%20003-Zaino%20Branching%2C%20Versioning%2C%20Documentation%2C%20Public%20Interfaces%2C%20and%20Release%20Strategy.md#1-branch--development-strategy): + +> **Branches** +> - `dev`: primary development branch (default branch). +> - `stable`: release branch (only release-quality changes land here). +> +> **PR targeting rules** +> - PRs may target `dev` directly. +> - PRs may target `stable` **only if they are merges from `dev`** (i.e., *no feature branches directly into stable*). +> +> **Review rules** +> - Merge into `dev`: **1 approval** from CODEOWNERS. +> - Merge into `stable`: **2 approvals** from CODEOWNERS. + +**Superseded by this ADR** (branch model and PR targeting): ADR 003 specifies +two branches (`dev`, `stable`) with PRs into `stable` coming directly from +`dev`. This repo specifies **four branches** — `dev → rc → release-ready → +stable` (see [Branch Model](#branch-model)). The Release PR into `stable` +originates from `release-ready`; `rc` and `release-ready` are advanced only by +CI fast-forward to gate-passing commits; the "no feature branches directly into +stable" invariant is preserved via the `rc`/`release-ready` intermediaries. The +sole path that reaches `stable` other than the Release PR is the emergency +[nuclear hotfix](#hotfix-protocol), reconciled by the sentinels. Where ADR 003 +and this section disagree on the branch graph or PR targeting, this ADR is +authoritative. + +**Inherited unchanged**: the 1-CODEOWNER approval requirement for merges into +`dev` and the 2-CODEOWNER approval requirement for merges into `stable`. + +### CI test execution (refined by this ADR) + +From [ADR 003 §1, "CI / test execution rules"](https://github.com/zingolabs/zingo-adrs/blob/dev/ADR%20003-Zaino%20Branching%2C%20Versioning%2C%20Documentation%2C%20Public%20Interfaces%2C%20and%20Release%20Strategy.md#1-branch--development-strategy): + +> - PRs into `dev`: run a **fast test set** (unit tests where available, small subset of integration tests included while unit tests are missing). +> - Nightly on `dev`: run the **full test suite**. +> - PRs into `stable` (i.e., `dev` → `stable` release PRs): run the **full test suite**. + +This ADR refines the two-tier model into three gates ([Gates](#gates)), each +running a [named suite](#named-suites). Mapping: + +| ADR 003 | This ADR | +| -------------------------------- | -------------------------------------------------------------- | +| Fast test set (PRs into `dev`) | `dev`-gate — `dev-gate` suite (unit + integration + e2e smoke) | +| Full suite (nightly on `dev`) | `rc`-gate — `rc-gate` suite (full e2e), nightly | +| — | `release`-gate — `release-gate` suite (deployment), continuous per RC | +| Full suite (`dev → stable` PR) | **Superseded**: no gate on the blessing merge | + +**Superseded by this ADR** (gate placement): ADR 003 places a full-suite gate +on the `dev → stable` PR. In this repo, the `release`-gate (deployment) runs against +`rc` commits (days-long each), and its outcome is recorded on the [Release +PR](#blessing-the-only-human-decision). The final merge that promotes +`release-ready` into `stable` is a manual blessing, not a gate — all three gates +have already been cleared by the commit being promoted. Where ADR 003 implies a +re-run of the full suite at merge-into-`stable` time, this ADR is +authoritative: no new suite runs at blessing; blessing is a deterministic +promotion of the most-advanced fully-gated commit. + +The `release`-gate is also genuinely new content: ADR 003's single "full suite" +collapsed integration and long-running deployment testing, which is incompatible with gating +a synchronous PR on days-long operations. + +### Dependency policy (inherited from ADR 003 §1) + +From [ADR 003 §1, "Dependency rules"](https://github.com/zingolabs/zingo-adrs/blob/dev/ADR%20003-Zaino%20Branching%2C%20Versioning%2C%20Documentation%2C%20Public%20Interfaces%2C%20and%20Release%20Strategy.md#1-branch--development-strategy): + +> All non-test dependencies must be crates.io imports on stable. +> Dev may temporarily use feature branches via `[patch.crates-io]`. + +### Versioning semantics (inherited from ADR 003 §2) + +From [ADR 003 §2, "Versioning strategy (SemVer)"](https://github.com/zingolabs/zingo-adrs/blob/dev/ADR%20003-Zaino%20Branching%2C%20Versioning%2C%20Documentation%2C%20Public%20Interfaces%2C%20and%20Release%20Strategy.md#2-versioning-strategy-semver-and-what-it-means-in-zaino): + +> Zaino follows **Semantic Versioning (SemVer)**: `MAJOR.MINOR.PATCH`. +> +> **Scope choice** +> - Zaino versions are treated as **crate-specific** meaning each publishable crates in this repository will have an individual version number which will be bumped when changes to that repo necessitate it. +> +> **Definitions for Zaino** +> - **MAJOR**: any *backward-incompatible* change to a governed public interface (see "Public interfaces" section), including: +> - breaking changes to gRPC service behavior/requests/responses, +> - removing or changing semantics/signatures of public Rust items intended for external users, +> - breaking configuration/CLI contract for `zainod` where it impacts operators in a non-compatible way. +> - **MINOR**: backward-compatible feature additions, including: +> - new RPC endpoints/services added without breaking existing ones, +> - new fields added in a backward-compatible way (where supported by the protocol/encoding), +> - new public Rust APIs that do not break old ones. +> - **PATCH**: backward-compatible bug fixes, performance fixes, and internal refactors with no externally observable contract change. + +**Pre-1.0 relaxation**, from the same section: + +> While Zaino remains in the 0.y.z phase, version bumps will be treated as one level "less critical" than post-1.0.0. Specifically, changes that would normally require a major bump will instead require a minor bump, and changes that would normally require a minor bump will instead require a patch bump. Patch bumps keep the same meaning as post-1.0.0. + +**ZainoDB versioning**, from the same section: + +> - **MAJOR**: Distinct database implementations, providing differing sets of functionality (Currently V1 is the only supported major version. A lightweight V2 database that only holds the minimal set of data required to produce the extra indexes (compared to zebrad) required in Zaino is planned but not yet implemented. The legacy V0 local-cache schema has been removed: an on-disk V0 database is no longer opened or migrated — it is rejected with an error directing the operator to resync a V1 database). +> - **MINOR**: Updates that contain changes to either the public APIs or the on disk schema. +> - **PATCH**: Internal bug fixes / performance improvements that do not touch the public APIs or on disk schema. +> +> Due to this, version changes in ZainoDB may not dictate a change of the same type at the library level. + +### Documentation publication (inherited from ADR 003 §3) + +From [ADR 003 §3, "GitHub Pages + crates.io documentation update strategy"](https://github.com/zingolabs/zingo-adrs/blob/dev/ADR%20003-Zaino%20Branching%2C%20Versioning%2C%20Documentation%2C%20Public%20Interfaces%2C%20and%20Release%20Strategy.md#3-github-pages--cratesio-documentation-update-strategy): + +> **Docs targets** +> - **GitHub Pages (gh-pages)**: the canonical "workspace documentation" site. +> - **docs.rs (crates.io)**: Rust API docs are automatically built for crates published to crates.io. +> +> **Update rules** +> - Every time `stable` is updated as part of a release (and crates.io is updated), **GitHub Pages MUST be updated** to match that release state. +> - docs.rs updates automatically when crates are published to crates.io. + +Implementation via `actions/deploy-pages` as part of the release workflow is +currently unimplemented; manual update of gh-pages at release time is required +until that is automated. + +### Changelog policy (inherited from ADR 003 §4) + +From [ADR 003 §4, "Changelog policy"](https://github.com/zingolabs/zingo-adrs/blob/dev/ADR%20003-Zaino%20Branching%2C%20Versioning%2C%20Documentation%2C%20Public%20Interfaces%2C%20and%20Release%20Strategy.md#4-changelog-policy): + +> **Changelog locations** +> - **Workspace changelog:** one primary changelog for the repository/workspace (covers cross-cutting changes and release-level summaries). +> - **Per-crate changelogs:** each publishable crate maintains its own changelog for crate-specific changes. +> - **ZainoDB changelog:** ZainoDB maintains an additional database-specific changelog, following the ZainoDB versioning policy defined in this ADR (separate from the crate/workspace SemVer policy). +> +> **What must be recorded** +> - Any change to a governed **public interface** (as defined in this ADR) must be recorded in: +> - the **workspace changelog**, and +> - the **relevant crate's changelog**. +> - Any change that affects the **ZainoDB on-disk schema** or database behaviour covered by the ZainoDB versioning policy must be recorded in the **ZainoDB changelog**, and does not necessarily imply a crate/workspace version bump of the same type. + +This ADR implements the recording mechanism via changesets (see +[Changesets](#changesets)): each governed public-interface change is declared in +its own `[[changes]]` entry, and CI aggregates the changesets accumulated since +the last stable to produce the workspace and per-crate changelogs at release +time. The ZainoDB changelog is maintained separately on the ZainoDB versioning +cadence. + +### Governed public interfaces (inherited from ADR 003 §5) + +From [ADR 003 §5, "Public interfaces governed by this ADR"](https://github.com/zingolabs/zingo-adrs/blob/dev/ADR%20003-Zaino%20Branching%2C%20Versioning%2C%20Documentation%2C%20Public%20Interfaces%2C%20and%20Release%20Strategy.md#5-public-interfaces-governed-by-this-adr-and-officially-supported-in-zaino): + +> This section defines the "compatibility surface" that drives SemVer bumps and stable-branch gatekeeping. + +**Authoritative crate list (this repo)**: [Context](#context) enumerates the +**23 crates.io-published packages** and **3 internal-only packages** (`e2e`, +`clientless`, `zaino-testutils`), mirroring the machine-read +[`relman.toml`](../../../relman.toml). This list has grown since ADR 003: +`zaino-fetch` was **deleted** and the source stack (`zaino-source*`, +`zaino-primitives`, `zaino-address`, `zaino-rpc`, `zaino-convert-zebra`) +**added** by ADR-0008; `integration` was renamed `clientless` by ADR-0004; and +`zaino-consensus`, `zaino-mempool`, `zaino-mempool-service`, `zaino-status` were +added later and brought under governance. The per-crate public-interface +subsections below still reflect the **older** set; deriving the governed +public-item lists for the source-stack, consensus, mempool, and status crates is +a **pending follow-up** (tracked with the drift note at the end of this +section). The release *mechanism* in the body above is unaffected — it operates +over whatever the current crate list is. + +`zainodlib` exists as a library target inside the `zainod` package +(`packages/zainod/Cargo.toml`: `[[bin]] name = "zainod"` alongside +`[lib] name = "zainodlib"`). It is **not** a first-class crates.io-published +package: it has no independent version number and is not `cargo publish`ed +separately. External consumers who import `zainodlib` do so by depending on +the `zainod` package. ADR 003 treats `zainodlib` as a distinct governed +interface surface, and its public-item list remains in force (below), but +its SemVer bumps are expressed through the `zainod` package version, not an +independent version of its own. Changes to `zainodlib`'s public API are +therefore recorded as governed public-interface changes on the `zainod` +crate for changeset purposes. + +`zaino-testvectors` is not in this repo. It has been extracted to a separate +repository/workspace and is now published independently to crates.io; its +release policy is governed there, not here. ADR 003's listing of it as an +excluded crate in this repo is therefore moot — it is out of scope entirely +for this ADR. The excluded (internal-only, not-crates.io-published) crate +list governed by this ADR is `e2e`, `clientless`, and `zaino-testutils`. + +The per-crate subsections below reproduce the public-interface and +public-item lists from ADR 003 verbatim. Subsection headers use the Rust +module form (underscore) to match ADR 003's original headings; the +corresponding package names (`Cargo.toml`) use the hyphenated form. + +#### `zainod` (daemon) + +> Public interfaces: +> - Zainod daemon: Main indexing daemon +> - Zcash JsonRPC service +> - Zcash LightClient gRPC service +> +> Public items: +> - CLI arguments +> - Config format +> - RPC Specs + +#### `zainodlib` (daemon library) + +> Public interfaces: +> - `indexer::Indexer`: Full indexing server +> +> Public items: +> - `config::*` +> - `error::*` + +#### `zaino_serve` (gRPC + JsonRPC servers) + +> Public interfaces: +> - `server::{grpc::TonicServer, jsonrpc::JsonRpcServer}`: gRPC / JsonRPC server implementations +> +> Public items: +> - `rpc::{GrpcClient, JsonRpcClient}` +> - `rpc::jsonrpc::service::ZcashIndexerRpc` +> - `server::config::*` +> - `server::error::*` + +#### `zaino_state` (core indexing library) + +> Public interfaces: +> - `chain_index::source::ValidatorConnector`: Validator agnostic Chain data fetch service +> - `chain_index::{NodeBackedChainIndex, NodeBackedChainIndexSubscriber}`: Core chain indexing service +> - `backends::{fetch::{FetchService, FetchServiceSubscriber}, state::{StateService, StateServiceSubscriber}}`: Indexing API (IndexerService / IndexerSubscriber) based on the zcash RPC services for compatibility, utilising Zaino's underlying indexing services +> +> Public items: +> - `indexer::{IndexerService, ZcashService, IndexerSubscriber, ZcashIndexer, LightWalletIndexer, LightWalletService}` +> - `chain_index::{ChainIndex, NonFinalizedSnapshot}` +> - `chain_index::source::{BlockchainSource, State, BlockchainSourceResult}` +> - `chain_index::encoding::*` +> - `chain_index::types::*` +> - `status::*` +> - `stream::*` +> - `config::*` +> - `error::*` +> - ZainoDB's on disk schema. + +#### `zaino_fetch` (Zcash-specific JsonRPC client + parsing) + +> **Note (this ADR):** `zaino_fetch` was deleted by ADR-0008; its +> responsibilities moved into the source stack. Retained here as the historical +> ADR-003 record until the source-stack crates' governed lists are derived. + +> Public interfaces: +> - `jsonrpc::connector::JsonRpcConnector`: Zcash specific JsonRPC client with full chain data fetch and block / transaction parsing capability +> +> Public items: +> - `chain::utils::ParseFromSlice` +> - `chain::transaction::*` +> - `chain::block::*` +> - `chain::error::*` +> - `jsonrpc::connector::test_node_and_return_url` +> - `jsonrpc::response::*` +> - `jsonrpc::error::*` + +#### `zaino_proto` (LightClient protocol implementation) + +> Public items: +> - `::*` + +#### `zaino_common` (common types + utilities) + +> Public items: +> - `::*` + +#### Excluded (not governed) + +> - `zaino-testutils` +> - `e2e` +> - `clientless` +> +> These may change freely without affecting SemVer, except where they force changes to governed public crates. + +(ADR 003's original excluded list named `integration` — since renamed +`clientless` (ADR-0004) — and `zaino-testvectors`, now in a separate repo with +its own crates.io cadence, out of scope here entirely.) + +> **Note** The codebase does not currently reflect this in some places, with entities that should be private currently publicised (or error / config types in the wrong locations). Where this is the case issues / PRs should be opened to provide fixes (make entities pub(crate) or move to the correct location), or a subsequent ADR opened to update the public interface officially maintained. + +### Release strategy (superseded by this ADR) + +ADR 003 §6 defined release prerequisites, steps, and cadence at a level that +left rc creation/validation and a concrete cadence as open TODOs. Those TODOs +are resolved in the body of this document: + +- **Cadence** — [Blessing: the Only Human Decision](#blessing-the-only-human-decision) +- **RC creation and validation** — [Promotion Flow](#promotion-flow), [The `rc`-gate](#the-rc-gate-nightly), [The `release`-gate](#the-release-gate-continuous-deployment) +- **Release steps** — [Blessing: the Only Human Decision](#blessing-the-only-human-decision) +- **Container image publication** — follows ADR 003 §6 step 7: images MUST be tagged with the release version (`vMAJOR.MINOR.PATCH`) and SHOULD also be tagged with the Git commit SHA (see [Release Identity](#release-identity-versions-tags-changesets)). + +Source: [ADR 003 §6, "Release strategy"](https://github.com/zingolabs/zingo-adrs/blob/dev/ADR%20003-Zaino%20Branching%2C%20Versioning%2C%20Documentation%2C%20Public%20Interfaces%2C%20and%20Release%20Strategy.md#6-release-strategy). diff --git a/docs/rpc_api.md b/docs/rpc_api.md index 1a731448..674718a0 100644 --- a/docs/rpc_api.md +++ b/docs/rpc_api.md @@ -24,6 +24,6 @@ Zaino Currently Serves the following gRPC services as defined in the [LightWalle - GetLightdInfo (Empty) returns (LightdInfo) ## Zcash RPC Services -Zaino has also committed to taking over responsibility for serving all [Zcash RPC Services](https://zcash.github.io/rpc/) required by non-validator (miner) clients from Zcashd. +Zaino has also committed to taking over responsibility for serving all [Zcash RPC Services](https://zcash.github.io/rpc/) required by non-validator (miner) clients from the legacy Zcash full node. A full specification of the Zcash RPC services served by Zaino, and their current state of development, can be seen [here](./Zaino-zcash-rpcs.pdf). diff --git a/docs/testing.md b/docs/testing.md index 9bfd2457..f853e1ce 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,67 +1,73 @@ # Testing -### Dependencies -1) [Zebrad](https://github.com/ZcashFoundation/zebra.git) -2) [Lightwalletd](https://github.com/zcash/lightwalletd.git) -3) [Zcashd, Zcash-Cli](https://github.com/zcash/zcash) - -### Tests -1) Symlink or copy compiled `zebrad`, `zcashd` and `zcash-cli` binaries to `zaino/live-tests/test_binaries/bins/*` -2) Add `zaino/live-tests/test_binaries/bins` to `$PATH` or to `$TEST_BINARIES_DIR` -3) Run `cargo nextest run` - -The expected versions of these binaries is detailed in the file ``.env.testing-artifacts`. - -## Cargo Make -Another method to work with tests is using `cargo make`, a Rust task runner and build tool. -This can be installed by running `cargo install --force cargo-make` which will install cargo-make in your ~/.cargo/bin. -From that point you will have two executables available: `cargo-make` (invoked with `cargo make`) and `makers` which is invoked directly and not as a cargo plugin. - -`cargo make help` -will print a help output. -`Makefile.toml` holds a configuration file. - -## Containerized test tasks (podman) - -The `makers` tasks below build and run the test suites inside a **podman** -container, so you don't need the validator binaries on your host `$PATH`. The -container image is built or pulled automatically on first run. - -The single front door is `makers test [SET]`, where `SET` defaults to `packages`: - -- `makers test` (or `makers test packages`) — the **packages** set: the - `packages/*` production-crate tests that need no live validator. -- `makers test e2e` / `makers test clientless` — one live partition. -- `makers test live` — both live partitions (`clientless` then `e2e`) against a - live validator, with a combined pass/fail summary. -- `makers test all` — the whole suite: packages then live. - -(`container-test`, `live-clientless`, and `live-e2e` are the internal engines -the `test` front door delegates to; invoke them directly only when you need to -forward engine flags.) - -### zcashd-backed tests are OFF by default - -zcashd is being deprecated, so `zcashd_support` is **opt-in, not a default** -feature (docs/adr/0005): every test path runs `--no-default-features`, so the -zcashd-backed tests are compiled out. There is **no implicit or env-var path** -to enable them — only the explicit flag: - -- pass the flag: `makers test --with-zcashd`, `makers test live --with-zcashd`, - or `makers test all --with-zcashd` (it adds `--features zcashd_support`) -- use the convenience task: `makers zcashd_test` (equivalent to - `makers test all --with-zcashd`) - -See `docs/adr/0001-zcashd-support-feature-gate.md` (and `0005` for the -default-off revision) for the rationale. - -### Test contention on lower-resource machines - -The suites run at full parallelism (one test thread per CPU), and each -live test can spawn its own validator. On machines with fewer cores or -less RAM this can surface as occasional flaky failures caused by contention -rather than real regressions — re-running usually passes. To make runs more -reliable, lower the parallelism by reducing `test-threads` in the single root -nextest config (`.config/nextest.toml`; the live tests are additionally capped -to 6 concurrent validators via the `live-validators` test-group). For a one-off -run you can instead forward a nextest flag through the front door, e.g. -`makers test --test-threads 6`. + +Zaino has two kinds of tests, run by two different commands: + +- **Unit tests** — the unit and crate-level integration tests of the + `packages/*` crates (the root workspace `default-members`). They need no live + validator and run on your host with a plain `cargo nextest run`. +- **Integration/Live tests** — tests that stand up a real validator, wallet against + regtest or testnet and exercise the assembled, running system. + They live in the standalone `live-tests/` workspace and run on the **ztest** + Kubernetes harness + +## Quick start + +```sh + +# Production crates, from the repo root. +cargo nextest run + +cd live-tests +ztest run + +# Run just the clientless tests (no wallet) +ztest run -p clientless + +# Run just the tests that failed last run +ztest run -p clientless --rerun latest + +``` + +## The test sets + +| Set | Where it runs | What it covers | +| ------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/*` | host (`cargo nextest`) | The production crates. No live validator — but *not* network-free: e.g. `zaino-serve`'s gRPC regression test binds a loopback socket and stands up a tonic server. | +| `e2e` | ztest / k8s | The partition driven end-to-end by a real wallet client through Zaino's gRPC surface to a live validator — a wallet's full-stack view of the indexer. | +| `clientless` | ztest / k8s | The partition that drives a deployed Zaino over its served gRPC and JSON-RPC surfaces against a live validator, with no wallet client — fetch-vs-state backend parity, and validator-vs-Zaino oracle checks. | + +## Local Ztest Cluster Setup + +```sh +# 1. The CLI. It is not a workspace member; it is expected on PATH. +cargo install ztest_cli --version '^0.1' --locked + +# 2. A cluster. Any reachable cluster works; kind is the usual local one. +# https://kind.sigs.k8s.io/docs/user/quick-start/#installation +kind create cluster --name ztest + +# 3. Register it with ztest and provision what the harness needs. +ztest cluster add kind --kind ztest --set-default +ztest cluster setup +# This ztest cluster setup can take a few minutes, and creates k8s namespaces, monitoring, etc. + +# Check if the cluster is ready/healthy +ztest cluster check +``` + +### Cluster storage + +ztest can load validator chaindata snapshots for integration or sync tests. For Sync +tests it is much faster to use a k8s storage driver that supports CoW `VolumeSnapshot` +operations. You can skip this if just running integration tests, or accept the +performance hit of copying the 40-200GB chaindata snapshots when starting a mainnet +validator + +```sh + +# TopoLVM is good option when backed by lvm thin-pools +ztest cluster add kind --kind ztest --storage-driver topolvm.io --set-default + +# Rook-ceph is the preferred central-cluster option +ztest cluster add kind --kind ztest --storage-driver rook-ceph.cephfs.csi.ceph.com --set-default +``` diff --git a/docs/updating_zebra_crates.md b/docs/updating_zebra_crates.md index 9db771d9..53e6137b 100644 --- a/docs/updating_zebra_crates.md +++ b/docs/updating_zebra_crates.md @@ -22,10 +22,6 @@ This baseline will tell you which tests are currently passing, failing and their performance. This will help you identify regressions when updating these or any other dependencies. -## update `.env.testing-artifacts` to the corresponding version of Zebra -Instructions on how to do this can be found in [testing](./testing.md) -documentation. - ## Finding out which crates depend on Zebra crates. Find out which dependencies use `zebra-*` crates by running `cargo tree` and spotting the usage of Zebra crates. @@ -38,7 +34,7 @@ order to catch any posible compile errors early. ## Keep the zebra pin in the single root workspace This repo is **one Cargo workspace** with a single `Cargo.lock`: the live-test -crates were folded into the root workspace (see docs/adr/0002, docs/adr/0003). +crates live in the standalone live-tests/ workspace. The zebra version requirements live once in the root `Cargo.toml` `[workspace.dependencies]`, and any unreleased pin lives once in the root `[patch.crates-io]`. Every member — the `packages/*` production crates and the diff --git a/docs/use_cases.md b/docs/use_cases.md index a19769d2..7e7afb86 100644 --- a/docs/use_cases.md +++ b/docs/use_cases.md @@ -1,6 +1,6 @@ # Indexer Live Service ### Dependencies -1) [Zebrad](https://github.com/ZcashFoundation/zebra.git) or [Zcashd, Zcash-Cli](https://github.com/zcash/zcash.git) +1) [Zebrad](https://github.com/ZcashFoundation/zebra.git) 2) [Zingolib](https://github.com/zingolabs/zingolib.git) [if running Zingo-Cli] ### Running ZainoD @@ -35,7 +35,7 @@ Zaino-State serves as Zaino's chain fetch and transaction submission library. Th The use of a `Service` and `ServiceSubscriber` separates the core chainstate maintainer processes from fetch fuctionality, enabling zaino to serve a large number of concurrent clients efficiently. In the future we will also be adding a lightweight tonic backend option for clients that do not want to run any chainstate processes locally. Currently 2 `Service's` are being implemented, with plans for several more: -- FetchService: Zcash JsonRPC powered backend service enabling compatibility with a large number of validator options (zcashd, zebrad). +- FetchService: Zcash JsonRPC powered backend service enabling compatibility with JsonRPC validator options (zebrad). - StateService: Highly efficient chain fetch service tailored to run with ZebraD. Future Planned backend Services: diff --git a/flake.lock b/flake.lock index 66b8ed77..fd1471f2 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1779130139, - "narHash": "sha256-BLrtr42azquO7MdGFU5a7KiMl3YpFlTeIXqy1fT5GlQ=", + "lastModified": 1785284101, + "narHash": "sha256-ghcXEpYEM4a7pbEkoqbn8c0ptJJqgGzuFiG3T6W5g4I=", "owner": "ipetkov", "repo": "crane", - "rev": "edb38893982a3338972bb4a2ec7ce7c29ba10fd9", + "rev": "756d6d07c3818ea95d1e2cdac63fa7d02fe3e61b", "type": "github" }, "original": { @@ -23,11 +23,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1779442979, - "narHash": "sha256-CtoDst6OfZYWAuqT7J5A9QduRYIL7+xTlBd9CS8RKOk=", + "lastModified": 1785400495, + "narHash": "sha256-1lYcRFp9AzhPjj0kY/5sru9vKFU4VV9v70iPQOayqIc=", "owner": "nix-community", "repo": "fenix", - "rev": "e8d2e251fa22fd892997612423e16c733a8775c4", + "rev": "a130a35700abbfb1e96ce47e4f0bee7e76ddfcc1", "type": "github" }, "original": { @@ -56,16 +56,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1779102034, - "narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=", + "lastModified": 1785386831, + "narHash": "sha256-sPS3CaXH8RAT3FZRuy4VcV47iuYIWMMfa0GbyJKC3o4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "687f05a9184cad4eaf905c48b63649e3a86f5433", + "rev": "21ea275a7c46aef9d4d6ddc962e6d562e9d94183", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.11", + "ref": "nixos-26.05", "repo": "nixpkgs", "type": "github" } @@ -81,11 +81,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1779303932, - "narHash": "sha256-alMOllPgosTsuXGjhvQvbGH0ekPOZW0QfGTI0NdJtbQ=", + "lastModified": 1785369962, + "narHash": "sha256-Mi11wW0wbfBtnq+0jLHzgSlvFvzAOSu+nTRsXEvmNZ8=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "7f916ab1b1f669cec017960c2d91a9a87b4b7bae", + "rev": "b2abc7e727d7139f94352c58f8f937ea875abb68", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 7c97d35b..2549289e 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "Zaino — indexer and proxy server for the Zcash protocol"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; flake-utils.url = "github:numtide/flake-utils"; crane.url = "github:ipetkov/crane"; @@ -28,7 +28,7 @@ (crane.mkLib pkgs).overrideToolchain (p: fenix.packages.${p.stdenv.buildPlatform.system}.fromToolchainFile { file = ./rust-toolchain.toml; - sha256 = "sha256-gh/xTkxKHL4eiRXzWv8KP7vfjSk61Iq48x47BEDFgfk="; + sha256 = "sha256-mvUGEOHYJpn3ikC5hckneuGixaC+yGrkMM/liDIDgoU="; }); overlay = final: _prev: { @@ -83,9 +83,17 @@ cargo-deny cargo-make rust-analyzer + + # Integration tests + kind + kubectl + openshift ]; - inherit (commonArgs) env; + env = commonArgs.env // { + # Needed for librocksdb-sys + LD_LIBRARY_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + }; }; checks = { diff --git a/live-tests/CLAUDE.md b/live-tests/CLAUDE.md new file mode 100644 index 00000000..e71e1771 --- /dev/null +++ b/live-tests/CLAUDE.md @@ -0,0 +1,106 @@ +# Live-test Contributor Guidelines + +The repo-root `CLAUDE.md` applies here in full. This file adds the rules that +are specific to the live suite, and overrides the root where it says so. + +## What this tree is + +Two test crates over a shared helper crate: + +- `e2e` — a wallet drives Zaino over gRPC/JSON-RPC. +- `clientless` — no wallet; tests gRPC driectly +- `zaino-testutils` — Test helpers and utilities + +`live-tests/` is a standalone Cargo workspace with its own lock. All the tests +in live-tests/ should be ztest-based and run inside the containerized zcash stack. +Any tests that need to validate zaino internal APIs and dont need a real validator +or wallet should be unit tests within the `packages/` directory. + +## Running + +Cluster setup and the `ztest` CLI install are in +[`docs/testing.md`](../docs/testing.md). + +- launch `ztest run` from within the `live-tests/` directory +- Most cargo nextest options work, like `-p`, `-E`, `--rerun latest` +- If you need to inspect the container after a test terminates, use + `ztest run --no-cleanup` and then `kubectl get pods` to see the cluster state + +Never `#[ignore]` a test that can run on the cluster. If a test is blocked, +write the **full body** and let it fail on-cluster; a red test is information, a +skipped one is not. + +## Every test owns its topology, inline + +Setup is written out in the test body. Do not hide it behind a helper in another +crate that takes eight booleans — that pattern is much harder to read. We DO NOT +want to use DRY principles within integration tests. We also should not put any +comments in test body. If we do need a comment, it MUST be 1-2 lines, and +informative + +```rust +let mut env = TestEnv::builder().ready_timeout(READY); +let validator = env.add_validator(Validator::zebrad("6.2.3").regtest()); +let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); +env.build().await?; +``` + +## Parameterize the axes, don't copy the test + +Two tests differing only by backend, validator, or pool are one `#[rstest]`. + +```rust +#[rstest] +#[case::fetch(Validator::zebrad("6.2.3"), Backend::Fetch)] +#[case::state(Validator::zebrad("6.2.3"), Backend::State)] +#[ztest::qos::integration] +#[tokio::test(flavor = "multi_thread")] +async fn block_count( + #[case] validator: Validator, + #[case] backend: Backend, +) -> Result<()> { +``` + +Zebra is the only supported backing validator, so the live axes are the ingest +path (`Fetch` / `State`) and the pool. `State` reads the validator's on-disk +zebra DB over a shared volume, so it exists only where that volume is mounted — +the axes are one flat case list, not a matrix. + +## Tag the QoS tier, and tag it honestly + +Every test carries exactly one `#[ztest::qos::*]`. The tier is a resource +reservation; getting it wrong OOM-kills pods and reads as flake. + +| Tier | Reserve | Cap | Use | +| ------------- | ------------ | ------ | --------------------------------------- | +| `basic` | 1c / 512 MiB | 1 min | No validator pod. | +| `wallet` | 4c / 2 GiB | 10 min | In-process wallet; proving work. | +| `integration` | 3c / 3 GiB | 10 min | ≤3-pod zaino topology, no wallet. | +| `testnet` | 8c / 10 GiB | 6 h | Snapshot-restored public chains. | +| `sync` | 15c / 15 GiB | 48 h | Long-running sync subjects (NVMe pool). | + +A regtest zebrad needs 1 GiB to itself — `basic` will kill it. Anything standing +up a validator is `integration` or heavier. + +## Runtime attributes + +The root `CLAUDE.md` rule ("start at `#[test]`, escalate only as the body +demands") does **not** apply here. Every live test drives pods over the network +and awaits concurrent readiness, so `#[tokio::test(flavor = "multi_thread")]` is +the floor. Do not downgrade one, and do not add a justifying comment — it is the +default for the whole tree. + +## Comments + +Follow ztest's comment discipline, which is stricter than the root file's: +notes, not prose; no restating the code; no provenance trivia. + +Specifically banned in this tree, because it is where the suite keeps regrowing +them: + +- **Migration archaeology.** "Port of `x`", "upstream did", "dev drove", "used + to live here". The commit message is where that goes. +- **Tombstones for deleted tests.** If a test is gone, it is gone. A comment + explaining an absence is unfalsifiable and never gets deleted. +- **Doc comments echoing the test name.** `/// Tests that the block count matches` above `async fn block_count` is zero information. Write what the test + *asserts* that the name does not say, or write nothing. diff --git a/live-tests/CONTEXT.md b/live-tests/CONTEXT.md deleted file mode 100644 index 1bef4076..00000000 --- a/live-tests/CONTEXT.md +++ /dev/null @@ -1,56 +0,0 @@ -# Live Tests - -The live-test suite for Zaino: tests that stand up real external processes (a -validator, and where applicable a wallet client) and exercise the assembled, -running system — kept apart from the fast unit/`container-test` development -flow. Split into two partitions, `e2e` and `clientless`. - -## Language - -**Live test**: -A test that requires a *live* validator process (Zebra or zcashd), and -optionally a live wallet client, to exercise Zaino against real external -infrastructure. The defining property of this suite and the reason it is -excluded from the default `container-test` flow and run only by its own task -family. Umbrella over the `e2e` and `clientless` partitions. -_Avoid_: integration test (now reserved for Cargo's sense — any `tests/*.rs`; -the no-client live partition is `clientless`, see docs/adr/0004), system test, -e2e test (means the `e2e` partition here). - -**e2e (test partition)**: -The partition driven end-to-end by a real wallet client (the `zcash_local_net` -devtool client) through Zaino's gRPC surface to a live validator. Tests a -wallet's full-stack view of the indexer. Formerly the `wallet-tests` package. -_Avoid_: wallet test, wallet-tests, lightclient. - -**clientless (test partition)**: -The partition that drives Zaino's service layer (`FetchService`/`StateService` -subscribers, RPC surface) directly against a live validator, with no wallet -client — fetch-vs-state and zcashd-vs-zainod oracle checks. Formerly the -`walletless-tests` package; named `integration` until docs/adr/0004 reverted -that to `clientless`. The crate/dir is `live-tests/clientless`; selected with -`-p clientless` or `makers test clientless`. -_Avoid_: walletless test, walletless-tests; integration (the former name — -reverted in docs/adr/0004, now reserved for Cargo's sense). - -**zaino-testutils**: -The client-agnostic test harness consumed by both the `e2e` and `clientless` -partitions. A standalone crate, not part of either partition; it -owns shared fixtures and the feature-forwarding surface (`zcashd_support`, -`transparent_address_history_experimental`). -_Avoid_: test helpers, testlib. - -**packages (test set)**: -The tests of the `packages/*` production crates — the workspace -`default-members`, exercised by bare `cargo nextest run` and by `makers test` -(the default with no argument, equivalently `makers test packages`). Named for -where the crates live (`packages/`); the functional reason they run apart from -the live partitions is that they need **no** live validator. They are *not* -network-free, though: e.g. zaino-serve's gRPC `spawn` regression test binds a -loopback socket and stands up a tonic server. The absent ingredient is a -validator, not the network. -_Avoid_: package (singular — the set is plural, matching the `packages/` dir; -`makers test package` now errors with `unknown set`); offline (overclaims "no -network" — these tests bind loopback sockets); unit test (the set includes -crate-level integration tests); container test (names the run mechanism, which -the live suite shares). diff --git a/live-tests/Cargo.lock b/live-tests/Cargo.lock new file mode 100644 index 00000000..1c7e16ef --- /dev/null +++ b/live-tests/Cargo.lock @@ -0,0 +1,6184 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bellman" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afceed28bac7f9f5a508bca8aeeff51cdfa4770c0b967ac55c621e2ddfd6171" +dependencies = [ + "bitvec", + "blake2s_simd", + "byteorder", + "crossbeam-channel", + "ff", + "group", + "lazy_static", + "log", + "num_cpus", + "pairing", + "rand_core 0.6.4", + "rayon", + "subtle", +] + +[[package]] +name = "bip0039" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da3803045ec5ba2ee8f65eb36b29115ec8a05fe519a7ae023c84770bd5f676b" +dependencies = [ + "anyhow", + "hmac 0.12.1", + "pbkdf2", + "phf", + "phf_codegen", + "rand 0.10.2", + "sha2 0.10.9", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "bip32" +version = "0.6.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "143f5327f23168716be068f8e1014ba2ea16a6c91e8777bc8927da7b51e1df1f" +dependencies = [ + "bs58", + "hmac 0.13.0-pre.4", + "rand_core 0.6.4", + "ripemd 0.2.0-pre.4", + "secp256k1", + "sha2 0.11.0-pre.4", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitflags-serde-legacy" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b64e60c28b6d25ad92e8b367801ff9aa12b41d05fc8798055d296bace4a60cc" +dependencies = [ + "bitflags 2.13.1", + "serde", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" +dependencies = [ + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "blake2s_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380c0236432f7b22a70229df30f218f5293870c056beb76f5ca068e3273a366" +dependencies = [ + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.11.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd016a0ddc7cb13661bf5576073ce07330a693f8608a1320b4e20561cc12cdc" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bls12_381" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bc6d6292be3a19e6379786dac800f551e5865a5bb51ebbe3064ab80433f403" +dependencies = [ + "ff", + "group", + "pairing", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bounded-vec" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dc0086e469182132244e9b8d313a0742e1132da43a08c24b9dd3c18e0faf3a" +dependencies = [ + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "sha2 0.10.9", + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytesize" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b" + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clientless" +version = "0.2.0" +dependencies = [ + "anyhow", + "rstest", + "serde_json", + "tokio", + "wire_serialized_transaction_test_data", + "zaino-testutils", + "ztest", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-crc32-nostd" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808ac43170e95b11dd23d78aa9eaac5bea45776a602955552c4e833f3f0f823d" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "corez" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4df6f98652d30167eaeea34d77b730e07c8caba6df17bd4551842b9b8da01deb" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b8ce8218c97789f16356e7896b3714f26c2ee1079b79c0b7ae7064bb9089fa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "daggy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70def8d72740e44d9f676d8dab2c933a236663d86dd24319b57a2bed4d694774" +dependencies = [ + "petgraph 0.7.1", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[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.119", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.0-pre.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2e3d6615d99707295a9673e889bf363a04b2a466bd320c65a72536f7577379" +dependencies = [ + "block-buffer 0.11.0-rc.3", + "crypto-common 0.2.0-rc.1", + "subtle", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "e2e" +version = "0.2.0" +dependencies = [ + "anyhow", + "rstest", + "serde_json", + "tokio", + "zaino-testutils", + "ztest", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-zebra" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775765289f7c6336c18d3d66127527820dd45ffd9eb3b6b8ee4708590e6c20f5" +dependencies = [ + "curve25519-dalek", + "ed25519", + "hashbrown 0.16.1", + "pkcs8", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "equihash" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "306286e8dcc39ab3dfceb74c792ce8baffdab90591321d3ffaae64829734c37f" +dependencies = [ + "blake2b_simd", + "corez", + "document-features", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "f4jumble" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d42773cb15447644d170be20231a3268600e0c4cea8987d013b93ac973d3cf7" +dependencies = [ + "blake2b_simd", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.8", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "fpe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c4b37de5ae15812a764c958297cfc50f5c010438f60c6ce75d11b802abd404" +dependencies = [ + "cbc", + "cipher", + "libm", + "num-bigint", + "num-integer", + "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 0.14.0", + "postcard", + "rand_core 0.6.4", + "serde", + "serdect", + "thiserror 2.0.20", + "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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "memuse", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "guppy-workspace-hack" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92620684d99f750bae383ecb3be3748142d6095760afd5cbcf2261e9a279d780" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "halo2_gadgets" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb2a697cad929f706b7987fe804ad57d43622cd37463ba7e4d662a926fdcfea3" +dependencies = [ + "arrayvec", + "bitvec", + "ff", + "group", + "halo2_poseidon", + "halo2_proofs", + "lazy_static", + "pasta_curves", + "rand 0.8.8", + "sinsemilla", + "subtle", + "uint 0.9.5", +] + +[[package]] +name = "halo2_legacy_pdqsort" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47716fe1ae67969c5e0b2ef826f32db8c3be72be325e1aa3c1951d06b5575ec5" + +[[package]] +name = "halo2_poseidon" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa3da60b81f02f9b33ebc6252d766f843291fb4d2247a07ae73d20b791fc56f" +dependencies = [ + "bitvec", + "ff", + "group", + "pasta_curves", +] + +[[package]] +name = "halo2_proofs" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5aca1c66059a919227dec97444a11a4350d2f9c820ca48690988f0aa0e81cbf" +dependencies = [ + "blake2b_simd", + "ff", + "group", + "halo2_legacy_pdqsort", + "indexmap 1.9.3", + "maybe-rayon", + "pasta_curves", + "rand_core 0.6.4", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "hdrhistogram" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" +dependencies = [ + "byteorder", + "num-traits", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0-pre.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4b1fb14e4df79f9406b434b60acef9f45c26c50062cccf1346c6103b8c47d58" +dependencies = [ + "digest 0.11.0-pre.9", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-http-proxy" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[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 = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "incrementalmerkletree" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30821f91f0fa8660edca547918dc59812893b497d07c1144f326f07fdd94aba9" +dependencies = [ + "either", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jubjub" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8499f7a74008aafbecb2a2e608a3e13e4dd3e84df198b604451efe93f2de6e61" +dependencies = [ + "bitvec", + "bls12_381", + "ff", + "group", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "k8s-openapi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" +dependencies = [ + "base64", + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "known-folders" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "kube" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4eb20010536b48abe97fec37d23d43069bcbe9686adcf9932202327bc5ca6e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1" +dependencies = [ + "base64", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy 0.10.3", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff0d0793db58e70ca6d689489183816cb3aa481673e7433dc618cf7e8007c675" +dependencies = [ + "chrono", + "form_urlencoded", + "http", + "json-patch", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "kube-runtime" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f34cfab9b4bd8633062e0e85edb81df23cb09f159f2e31c60b069ae826ffdc" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "async-trait", + "backon", + "educe", + "futures", + "hashbrown 0.15.5", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memuse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d97bbf43eb4f088f8ca469930cde17fa036207c9a5e02ccc5107c4e8b17c964" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +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 = "nextest-metadata" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0ba8403357e9701d6322b7ffb9dedc32e3843d0914d791c4c7a03867381615" +dependencies = [ + "camino", + "nextest-workspace-hack", + "serde", + "serde_json", + "smol_str", + "target-spec", +] + +[[package]] +name = "nextest-workspace-hack" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d906846a98739ed9d73d66e62c2641eef8321f1734b7a1156ab045a0248fb2b3" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonempty" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orchard" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cb2b35534bba3c63fbf640dc6cd9dfd1ece2fae886cdab4ab1f1e380dd6ca1" +dependencies = [ + "aes", + "bitvec", + "blake2b_simd", + "corez", + "ff", + "fpe", + "getset", + "group", + "halo2_gadgets", + "halo2_poseidon", + "halo2_proofs", + "hex", + "incrementalmerkletree", + "lazy_static", + "memuse", + "nonempty", + "pasta_curves", + "rand 0.8.8", + "rand_core 0.6.4", + "reddsa", + "serde", + "sinsemilla", + "subtle", + "tracing", + "visibility", + "zcash_note_encryption", + "zcash_spec", + "zip32", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "pairing" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" +dependencies = [ + "group", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pasta_curves" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3437083215c505e867eea5478371feba43d7689d6d15ec0a209eb46fb0d4cda6" +dependencies = [ + "blake2b_simd", + "ff", + "group", + "lazy_static", + "rand 0.8.8", + "static_assertions", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "password-hash", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +dependencies = [ + "pest", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.2", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.2", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint 0.9.5", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prometheus-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "811031bea65e5a401fb2e1f37d802cca6601e204ac463809a3189352d13b78a5" +dependencies = [ + "chrono", + "itertools 0.12.1", + "once_cell", + "regex", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "petgraph 0.8.3", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[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 = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reddsa" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4784b85c8bfd17b36b86e664e6e504ecdb586001086ee23749e4a633bbb84832" +dependencies = [ + "blake2b_simd", + "byteorder", + "frost-rerandomized", + "group", + "hex", + "jubjub", + "pasta_curves", + "rand_core 0.6.4", + "serde", + "thiserror 2.0.20", + "zeroize", +] + +[[package]] +name = "redjubjub" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b0ac1bc6bb3696d2c6f52cff8fba57238b81da8c0214ee6cd146eb8fde364e" +dependencies = [ + "rand_core 0.6.4", + "reddsa", + "serde", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "ripemd" +version = "0.2.0-pre.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48cf93482ea998ad1302c42739bc73ab3adc574890c373ec89710e219357579" +dependencies = [ + "digest 0.11.0-pre.9", +] + +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "time", + "uuid", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sapling-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d70756ede56b5e4dd417979777bd87ddb83dfcbd0815dbf8175a9920537f8a0" +dependencies = [ + "aes", + "bellman", + "bitvec", + "blake2b_simd", + "blake2s_simd", + "bls12_381", + "corez", + "document-features", + "ff", + "fpe", + "getset", + "group", + "hex", + "incrementalmerkletree", + "jubjub", + "lazy_static", + "memuse", + "rand 0.8.8", + "rand_core 0.6.4", + "redjubjub", + "subtle", + "tracing", + "zcash_note_encryption", + "zcash_spec", + "zip32", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + +[[package]] +name = "schemerz" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e82960ac11ccabd77d53c933532612079e01205b5873ec5095f4b3426493434" +dependencies = [ + "daggy", + "indexmap 1.9.3", + "log", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "schemerz-rusqlite" +version = "0.370.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6df8a13fb3b7914f94ac6579bd5e76b9292f135886e6becc3525f9e5116827" +dependencies = [ + "rusqlite", + "schemerz", + "uuid", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[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 = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0-pre.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "540c0893cce56cdbcfebcec191ec8e0f470dd1889b6e7a0b503e310a94a168f5" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.11.0-pre.9", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shardtree" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8147447aed7be4736e271825b8c3a4432efb7182e71363169b572371ddef452e" +dependencies = [ + "bitflags 2.13.1", + "either", + "incrementalmerkletree", + "tracing", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sinsemilla" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d268ae0ea06faafe1662e9967cd4f9022014f5eeb798e0c302c876df8b7af9c" +dependencies = [ + "group", + "pasta_curves", + "subtle", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +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 2.0.119", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "target-spec" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e117cdea67a83bf392bc69fb07cfc37e948e95991eca900c413a2bb1b494807" +dependencies = [ + "cfg-expr", + "guppy-workspace-hack", + "serde", + "serde_json", + "target-lexicon", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[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.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.2", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "base64", + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "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 = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "uint" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9227a75a5a540a464c832ad4a4195dbdbecd8787610a56262721fde6f04f90" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wagyu-zcash-parameters" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c904628658374e651288f000934c33ef738b2d8b3e65d4100b70b395dbe2bb" +dependencies = [ + "wagyu-zcash-parameters-1", + "wagyu-zcash-parameters-2", + "wagyu-zcash-parameters-3", + "wagyu-zcash-parameters-4", + "wagyu-zcash-parameters-5", + "wagyu-zcash-parameters-6", +] + +[[package]] +name = "wagyu-zcash-parameters-1" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bf2e21bb027d3f8428c60d6a720b54a08bf6ce4e6f834ef8e0d38bb5695da8" + +[[package]] +name = "wagyu-zcash-parameters-2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616ab2e51e74cc48995d476e94de810fb16fc73815f390bf2941b046cc9ba2c" + +[[package]] +name = "wagyu-zcash-parameters-3" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14da1e2e958ff93c0830ee68e91884069253bf3462a67831b02b367be75d6147" + +[[package]] +name = "wagyu-zcash-parameters-4" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f058aeef03a2070e8666ffb5d1057d8bb10313b204a254a6e6103eb958e9a6d6" + +[[package]] +name = "wagyu-zcash-parameters-5" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffe916b30e608c032ae1b734f02574a3e12ec19ab5cc5562208d679efe4969d" + +[[package]] +name = "wagyu-zcash-parameters-6" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b6d5a78adc3e8f198e9cd730f219a695431467f7ec29dcfc63ade885feebe1" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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 = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.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", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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 = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wire_serialized_transaction_test_data" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebb4f5d78d0832cc4a1760a20288470c6fafa02f18e555a7ece133cd8cef5ca" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + +[[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 2.0.119", + "synstructure", +] + +[[package]] +name = "zaino-proto" +version = "0.6.0" +dependencies = [ + "prost", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + +[[package]] +name = "zaino-testutils" +version = "0.2.0" +dependencies = [ + "anyhow", + "hex", + "prost", + "serde_json", + "thiserror 2.0.20", + "tokio", + "wire_serialized_transaction_test_data", + "zaino-proto", + "zebra-chain", + "ztest", +] + +[[package]] +name = "zcash_address" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a854b28c07dba372f4410ea8ad62b4bf7d5c2bf8be32fc4b31bc0db6521a975" +dependencies = [ + "bech32", + "bs58", + "corez", + "f4jumble", + "zcash_encoding", + "zcash_protocol", +] + +[[package]] +name = "zcash_client_backend" +version = "0.24.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb0d78dcc345c36086112886223f6f1d6d224802808f15ac4f3ef144677029a1" +dependencies = [ + "async-trait", + "base64", + "bech32", + "bip32", + "bls12_381", + "bs58", + "byteorder", + "document-features", + "flume", + "futures-util", + "getset", + "group", + "hex", + "hyper-util", + "incrementalmerkletree", + "memuse", + "nonempty", + "orchard", + "pasta_curves", + "percent-encoding", + "prost", + "rand_core 0.6.4", + "rayon", + "sapling-crypto", + "secp256k1", + "secrecy 0.8.0", + "shardtree", + "subtle", + "time", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "which", + "zcash_address", + "zcash_encoding", + "zcash_keys", + "zcash_note_encryption", + "zcash_primitives 0.29.0", + "zcash_protocol", + "zcash_script", + "zcash_transparent 0.9.0", + "zip32", + "zip321", +] + +[[package]] +name = "zcash_client_sqlite" +version = "0.22.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa9b039e66a3b79f2de45ebf86cd82545232d332631a84f7c017ae21572b8de" +dependencies = [ + "bip32", + "bitflags 2.13.1", + "bs58", + "byteorder", + "document-features", + "group", + "hex", + "incrementalmerkletree", + "jubjub", + "maybe-rayon", + "nonempty", + "orchard", + "prost", + "rand 0.8.8", + "rand_core 0.6.4", + "rand_distr", + "regex", + "rusqlite", + "sapling-crypto", + "schemerz", + "schemerz-rusqlite", + "secp256k1", + "secrecy 0.8.0", + "shardtree", + "static_assertions", + "subtle", + "time", + "tracing", + "uuid", + "zcash_address", + "zcash_client_backend", + "zcash_encoding", + "zcash_keys", + "zcash_primitives 0.29.0", + "zcash_protocol", + "zcash_script", + "zcash_transparent 0.9.0", + "zip32", +] + +[[package]] +name = "zcash_encoding" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1440921903cdb86133fb9e2fe800be488015db2939a30bedb413078a1acb0306" +dependencies = [ + "corez", + "hex", + "nonempty", +] + +[[package]] +name = "zcash_history" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d2ed9a9fa7b466a7adedab1ad5a21f8330e4943756912fc776cf7f3beae32b" +dependencies = [ + "blake2b_simd", + "byteorder", + "primitive-types", +] + +[[package]] +name = "zcash_keys" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36391ebfce7df2510c6564f79e608ed93f1c0692c6464e2397b248e06dae3912" +dependencies = [ + "bech32", + "bip32", + "blake2b_simd", + "bls12_381", + "bs58", + "corez", + "document-features", + "group", + "memuse", + "nonempty", + "orchard", + "rand_core 0.6.4", + "sapling-crypto", + "secrecy 0.8.0", + "subtle", + "tracing", + "zcash_address", + "zcash_encoding", + "zcash_protocol", + "zcash_transparent 0.9.0", + "zip32", +] + +[[package]] +name = "zcash_note_encryption" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cb1b9170c94370e3d66c5cc0877661db743337588b64de7711239eed462198" +dependencies = [ + "chacha20 0.9.1", + "chacha20poly1305", + "cipher", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "zcash_primitives" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdbeccc05bfe63b6dee9e989c6ff5027421741562a4853c36504dd621f6a81b1" +dependencies = [ + "blake2b_simd", + "block-buffer 0.11.0-rc.3", + "corez", + "crypto-common 0.2.0-rc.1", + "document-features", + "equihash", + "ff", + "hex", + "incrementalmerkletree", + "jubjub", + "memuse", + "nonempty", + "orchard", + "rand_core 0.6.4", + "redjubjub", + "sapling-crypto", + "secp256k1", + "sha2 0.10.9", + "zcash_encoding", + "zcash_note_encryption", + "zcash_protocol", + "zcash_script", + "zcash_transparent 0.9.0", +] + +[[package]] +name = "zcash_primitives" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403d5be1e96339534be098e3377fb8a78d68ca7585b1780133d884b810277418" +dependencies = [ + "blake2b_simd", + "block-buffer 0.11.0-rc.3", + "corez", + "crypto-common 0.2.0-rc.1", + "document-features", + "equihash", + "ff", + "hex", + "incrementalmerkletree", + "jubjub", + "memuse", + "nonempty", + "orchard", + "rand_core 0.6.4", + "redjubjub", + "sapling-crypto", + "secp256k1", + "sha2 0.10.9", + "zcash_encoding", + "zcash_note_encryption", + "zcash_protocol", + "zcash_script", + "zcash_transparent 0.10.0", +] + +[[package]] +name = "zcash_proofs" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91fb0b420fb66a765f1fa92ab7a6b631aa54c08415415ef6185256826e74fbd" +dependencies = [ + "bellman", + "blake2b_simd", + "bls12_381", + "document-features", + "group", + "home", + "jubjub", + "known-folders", + "rand_core 0.6.4", + "redjubjub", + "sapling-crypto", + "tracing", + "wagyu-zcash-parameters", + "xdg", + "zcash_primitives 0.29.0", +] + +[[package]] +name = "zcash_protocol" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "314329b91ec4bbb517441840e47d0b2029bf0b946f086980c96c889c2d92dc5d" +dependencies = [ + "corez", + "document-features", + "hex", + "memuse", + "zcash_encoding", +] + +[[package]] +name = "zcash_script" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f872800287d118be71bdf6fe8c869c6a6ff6fb0a5762f68fb2af54c97edf0f2" +dependencies = [ + "bip32", + "bitflags 2.13.1", + "bounded-vec", + "hex", + "ripemd 0.1.3", + "secp256k1", + "sha1", + "sha2 0.10.9", + "thiserror 2.0.20", +] + +[[package]] +name = "zcash_spec" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded3f58b93486aa79b85acba1001f5298f27a46489859934954d262533ee2915" +dependencies = [ + "blake2b_simd", +] + +[[package]] +name = "zcash_transparent" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e0f8c142bed366ed5886dcfa8772ec90b5420cc127e6cdb76b6744c53a287b" +dependencies = [ + "bip32", + "bs58", + "corez", + "document-features", + "getset", + "hex", + "nonempty", + "ripemd 0.1.3", + "secp256k1", + "sha2 0.10.9", + "subtle", + "zcash_address", + "zcash_encoding", + "zcash_protocol", + "zcash_script", + "zcash_spec", + "zip32", +] + +[[package]] +name = "zcash_transparent" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "547c012778bae17f58007731af074d638aa146ab0ecfc120adebf23d049aff6c" +dependencies = [ + "bip32", + "bs58", + "corez", + "document-features", + "getset", + "hex", + "nonempty", + "ripemd 0.1.3", + "secp256k1", + "sha2 0.10.9", + "subtle", + "zcash_address", + "zcash_encoding", + "zcash_protocol", + "zcash_script", + "zcash_spec", + "zip32", +] + +[[package]] +name = "zebra-chain" +version = "12.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b270bc7ec8f48cf58d14368c3521201947df137fc0e52633e81297250b2e3e0e" +dependencies = [ + "bech32", + "bitflags 2.13.1", + "bitflags-serde-legacy", + "bitvec", + "blake2b_simd", + "blake2s_simd", + "bounded-vec", + "bs58", + "byteorder", + "chrono", + "derive-getters", + "dirs", + "ed25519-zebra", + "equihash", + "futures", + "group", + "halo2_proofs", + "hex", + "humantime", + "incrementalmerkletree", + "itertools 0.14.0", + "jubjub", + "lazy_static", + "num-integer", + "orchard", + "primitive-types", + "rand_core 0.6.4", + "rayon", + "reddsa", + "redjubjub", + "ripemd 0.1.3", + "sapling-crypto", + "schemars 1.2.2", + "secp256k1", + "serde", + "serde-big-array", + "serde_with", + "sha2 0.10.9", + "sinsemilla", + "static_assertions", + "strum", + "tempfile", + "thiserror 2.0.20", + "tracing", + "uint 0.10.1", + "x25519-dalek", + "zcash_address", + "zcash_encoding", + "zcash_history", + "zcash_note_encryption", + "zcash_primitives 0.30.1", + "zcash_protocol", + "zcash_script", + "zcash_transparent 0.10.0", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "indexmap 2.14.2", + "memchr", + "thiserror 2.0.20", +] + +[[package]] +name = "zip32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b64bf5186a8916f7a48f2a98ef599bf9c099e2458b36b819e393db1c0e768c4b" +dependencies = [ + "bech32", + "blake2b_simd", + "memuse", + "subtle", + "zcash_spec", +] + +[[package]] +name = "zip321" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0180028b8807c60498f01eb805a125eed095ab4c4a1737635c80e6ef7cb0a9c" +dependencies = [ + "base64", + "nom", + "percent-encoding", + "zcash_address", + "zcash_protocol", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "ztest" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e6b55971cb0d184b9f7fa9b45bb845dcc16b8c35ca1a48a8c8c9f9ff867aae" +dependencies = [ + "async-trait", + "base64", + "bip0039", + "blake3", + "bytesize", + "chrono", + "console", + "ctor", + "directories", + "futures", + "globset", + "hdrhistogram", + "hex", + "http", + "inventory", + "k8s-openapi", + "kube", + "libc", + "nextest-metadata", + "orchard", + "owo-colors", + "proc-macro2", + "prometheus-parse", + "prost", + "rand 0.8.8", + "reqwest", + "rusqlite", + "rustls", + "sapling-crypto", + "secrecy 0.8.0", + "serde", + "serde_json", + "serde_yaml", + "sha2 0.10.9", + "supports-color", + "supports-unicode", + "syn 2.0.119", + "tar", + "tempfile", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "toml 0.8.23", + "tonic", + "tonic-prost", + "tracing", + "tracing-subscriber", + "walkdir", + "zcash_client_backend", + "zcash_client_sqlite", + "zcash_keys", + "zcash_primitives 0.29.0", + "zcash_proofs", + "zcash_protocol", + "zip", + "zstd", + "ztest_attr", + "ztest_macros", +] + +[[package]] +name = "ztest_attr" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd30c1ce9d86e907f568792177fcf38999a339de684488356ca685003e8c8c87" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "ztest_macros" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a2ff4f526f950c41a203ad56e9fb7d6cea21f1f34974735ebd4c57554d2e4c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "toml 0.9.12+spec-1.1.0", + "ztest_attr", +] diff --git a/live-tests/Cargo.toml b/live-tests/Cargo.toml new file mode 100644 index 00000000..8a4c4ac5 --- /dev/null +++ b/live-tests/Cargo.toml @@ -0,0 +1,32 @@ +[workspace] +members = [ + "zaino-testutils", + "clientless", + "e2e", +] +resolver = "2" + +[workspace.package] +authors = ["Zingo Lab Cyan"] +repository = "https://github.com/zingolabs" +homepage = "https://www.zingolabs.org/" +edition = "2021" +license = "Apache-2.0" +version = "0.2.0" + +[workspace.dependencies] +zaino-testutils = { path = "zaino-testutils" } +ztest = "0.1" +tokio = { version = "1.38", features = ["full"] } +anyhow = "1.0" +serde_json = { version = "1.0", features = ["preserve_order"] } +rstest = "0.23" + +# `legacy_parser` (the independent second parser the transaction vectors compare +# against) and the vectors it parses. Path deps into packages/ are limited to +# `zaino-proto`, whose generated compact-format types the parser renders into. +zaino-proto = { path = "../packages/zaino-proto", default-features = false } +zebra-chain = "12.0" +prost = "0.14" +thiserror = "2.0" +wire_serialized_transaction_test_data = "1.0.0" diff --git a/live-tests/clientless/Cargo.toml b/live-tests/clientless/Cargo.toml index 09134217..1d21993a 100644 --- a/live-tests/clientless/Cargo.toml +++ b/live-tests/clientless/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clientless" -description = "Clientless live tests: exercise Zaino's service layer directly against a live validator (no wallet client)." +description = "Clientless live tests: exercise Zaino's service layer over its gRPC/JSON-RPC surface against a live validator, on the ztest k8s harness (no wallet client)." publish = false authors.workspace = true @@ -13,69 +13,18 @@ version = "0.2.0" [features] default = [] -# Support for the zcashd test validator. Opt-in (default off, docs/adr/0005); being deprecated. Gates -# the zcashd-backed tests and forwards to zaino-testutils. See -# docs/adr/0001-zcashd-support-feature-gate.md. -zcashd_support = ["zaino-testutils/zcashd_support"] - -# **Experimental and alpha features** -# Exposes the **complete** set of experimental / alpha features currently implemented in Zaino. -experimental_features = ["transparent_address_history_experimental"] - -# Activates transparent address history capability in zaino -# -# NOTE: currently this is only implemented in the finalised state. -transparent_address_history_experimental = [ - "zaino-state/transparent_address_history_experimental", - "zainod/transparent_address_history_experimental", - "zaino-testutils/transparent_address_history_experimental", -] - [dependencies] -zaino-testutils = { workspace = true } -# The lib's z_validate helper calls `ZcashIndexer::z_validate_address`. -zaino-state = { workspace = true, features = ["test_dependencies"] } -# The lib's get_block_header oracle helper matches on `zebra_rpc::methods::GetBlock`. -zebra-rpc = { workspace = true } -# The lib's z_validate helper asserts on `zaino_address::ZValidatedAddress`. -zaino-address = { workspace = true } -# Decodes the hex z_validateaddress test vectors into the domain type's byte arrays. -hex = { workspace = true } - -[dev-dependencies] +ztest = { workspace = true } anyhow = { workspace = true } -# Enable the tractable seam depth (fast-test-seam) so the chain_cache tests exercise -# finalisation/eviction at small chain heights. Dev-dependency only — the feature -# must never reach a shipped build (see the zaino-state feature comment). -zaino-state = { workspace = true, features = ["fast-test-seam"] } +serde_json = { workspace = true } -# Test utility -# Not imported by any test source; present so the -# transparent_address_history_experimental feature can forward to -# zainod/transparent_address_history_experimental in the spawned daemon. -zainod = { workspace = true } +[dev-dependencies] zaino-testutils = { workspace = true } -zaino-proto = { workspace = true } -zaino-common = { workspace = true } -wire_serialized_transaction_test_data = { workspace = true } -zebra-chain = { workspace = true } -zebra-state = { workspace = true } -zebra-rpc = { workspace = true } - -futures = { workspace = true } -hex = { workspace = true } - -# Runtime +ztest = { workspace = true } +anyhow = { workspace = true } +serde_json = { workspace = true } tokio = { workspace = true } - -# Zingo-infra -zcash_local_net = { workspace = true } -# The served JSON schema. These tests compare Zaino's domain answers against a -# raw JSON-RPC oracle, so they render the domain side through the same wire -# types the server uses. -zaino-serve = { workspace = true } -serde_json.workspace = true -zaino-primitives.workspace = true -zaino-rpc.workspace = true -zaino-consensus.workspace = true -zaino-status.workspace = true +rstest.workspace = true +# `test_vectors.rs` parses committed transaction vectors through the +# independent `legacy_parser`; no validator, no cluster. +wire_serialized_transaction_test_data = { workspace = true } diff --git a/live-tests/clientless/src/lib.rs b/live-tests/clientless/src/lib.rs index 4982272f..792230e4 100644 --- a/live-tests/clientless/src/lib.rs +++ b/live-tests/clientless/src/lib.rs @@ -2,141 +2,53 @@ //! //! This crate also exposes test-vectors. -/// Assert that `oracle` and `subject` return the same `getblockheader` response for -/// the block at `height`: look the block up on the oracle (verbosity 1) to learn its -/// hash, then compare the two servers' non-verbose header responses for that hash. -/// Shared body of the per-backend `get_block_header` oracle tests. -#[allow(deprecated)] -pub async fn assert_get_block_header_matches( - oracle: &Oracle, - subject: &Subject, - height: u32, -) where - Oracle: zaino_state::ZcashIndexer, - Subject: zaino_state::ZcashIndexer, -{ - let block = oracle - .z_get_block(height.to_string(), Some(1)) - .await - .unwrap(); - - let block_hash = match block { - zebra_rpc::methods::GetBlock::Object(block) => block.hash(), - zebra_rpc::methods::GetBlock::Raw(_) => panic!("Expected block object"), - }; - - let oracle_header = oracle - .get_raw_block_header(block_hash.to_string()) - .await - .unwrap(); - - let subject_header = subject - .get_raw_block_header(block_hash.to_string()) - .await - .unwrap(); - assert_eq!(oracle_header, subject_header); -} - pub mod rpc { pub mod json_rpc { - pub const VALID_P2PKH_ADDRESS: &str = "tmVqEASZxBNKFTbmASZikGa5fPLkd68iJyx"; - pub const VALID_P2SH_ADDRESS: &str = "t2MjoXQ2iDrjG9QXNZNCY9io8ecN4FJYK1u"; - - // Sprout vectors deliberately absent: Zaino does not classify Sprout - // addresses, so there is nothing for a live test to assert beyond - // "invalid", which `zaino-address`'s own tests already pin. - + // Only the Sapling vector is kept: it is the one kind carrying key + // material, so it exercises every part of the served shape. The full + // per-kind table (including Sprout, which Zaino reports invalid rather + // than classifying) is a unit test in `zaino-serve`'s `wire::address`. pub const VALID_SAPLING_ADDRESS: &str = "zregtestsapling1jalqhycwumq3unfxlzyzcktq3n478n82k2wacvl8gwfxk6ahshkxmtp2034qj28n7gl92ka5wca"; pub const VALID_DIVERSIFIER: &str = "977e0b930ee6c11e4d26f8"; pub const VALID_DIVERSIFIED_TRANSMISSION_KEY: &str = "553ef2f328096a7c2aac6dec85b76b6b9243e733dc9db2eacce3eb8c60592c88"; - - pub const VALID_UNIFIED_ADDRESS: &str = "uregtest1njwg60x0jarhyuuxrcdvw854p68cgdfe85822lmclc7z9vy9xqr7t49n3d97k2dwlee82skwwe0ens0rc06p4vr04tvd3j9ckl3qry83ckay4l4ngdq9atg7vuj9z58tfjs0mnsgyrnprtqfv8almu564z498zy6tp2aa569tk8fyhdazyhytel2m32awe4kuy6qq996um3ljaajj36"; } pub mod z_validate_address { - use std::future::Future; + use anyhow::{Context, Result}; + use serde_json::{json, Value}; + use ztest::prelude::JsonRpcClient; use crate::rpc::json_rpc::{ - VALID_DIVERSIFIED_TRANSMISSION_KEY, VALID_DIVERSIFIER, VALID_P2PKH_ADDRESS, - VALID_P2SH_ADDRESS, VALID_SAPLING_ADDRESS, VALID_UNIFIED_ADDRESS, + VALID_DIVERSIFIED_TRANSMISSION_KEY, VALID_DIVERSIFIER, VALID_SAPLING_ADDRESS, }; - use zaino_address::ZValidatedAddress; - #[allow(deprecated)] - use zaino_state::ZcashIndexer; - - /// Decodes one of the hex test vectors into the fixed-size array the - /// domain type carries. The vectors are hex because they were captured - /// from zcashd's JSON output; the domain type is bytes because hex - /// encoding is the wire layer's job. - fn vector_bytes(hex_vector: &str) -> [u8; N] { - hex::decode(hex_vector) - .expect("test vector is valid hex") - .try_into() - .expect("test vector has the expected length") - } - - pub async fn run_z_validate_suite(rpc_call: &F) - where - // Any callable that takes an address and returns the response (you can unwrap inside) - F: Fn(String) -> Fut, - Fut: Future, - { - assert_eq!( - rpc_call(VALID_P2PKH_ADDRESS.to_string()).await, - ZValidatedAddress::P2pkh { - address: VALID_P2PKH_ADDRESS.to_string() - }, - "mismatch for P2PKH", - ); - - assert_eq!( - rpc_call(VALID_P2SH_ADDRESS.to_string()).await, - ZValidatedAddress::P2sh { - address: VALID_P2SH_ADDRESS.to_string() - }, - "mismatch for P2SH", - ); - // Sprout is not classified: `ZValidatedAddress` has no Sprout - // variant, and a Sprout address is reported invalid. See that - // type's Sprout note. + /// Asserts the served `z_validateaddress` endpoint reaches the + /// classifier and renders its full shape, key material included. + /// + /// Classification itself is not retested here — a live topology adds + /// nothing to a pure function of (address, network). The kind is + /// checked under both `address_type` and the legacy `type`, because + /// serving it twice is a wire contract nothing else pins on the wire. + pub async fn run_z_validate_for(irpc: &JsonRpcClient) -> Result<()> { + let resp: Value = irpc + .call_value("z_validateaddress", json!([VALID_SAPLING_ADDRESS])) + .await + .with_context(|| format!("z_validateaddress {VALID_SAPLING_ADDRESS}"))?; assert_eq!( - rpc_call(VALID_SAPLING_ADDRESS.to_string()).await, - ZValidatedAddress::Sapling { - address: VALID_SAPLING_ADDRESS.to_string(), - diversifier: vector_bytes(VALID_DIVERSIFIER), - diversified_transmission_key: vector_bytes(VALID_DIVERSIFIED_TRANSMISSION_KEY), - }, - "mismatch for Sapling", + resp, + json!({ + "isvalid": true, + "address": VALID_SAPLING_ADDRESS, + "type": "sapling", + "address_type": "sapling", + "diversifier": VALID_DIVERSIFIER, + "diversifiedtransmissionkey": VALID_DIVERSIFIED_TRANSMISSION_KEY, + }), + "z_validateaddress served shape" ); - - // Unified (differs by validator) - assert_eq!( - rpc_call(VALID_UNIFIED_ADDRESS.to_string()).await, - ZValidatedAddress::Unified { - address: VALID_UNIFIED_ADDRESS.to_string() - }, - "mismatch for Unified", - ); - - // Invalids - let by_len = rpc_call("t1123456789ABCDEFGHJKLMNPQRSTUVWXY".to_string()).await; - let all_zeroes = rpc_call("t1000000000000000000000000000000000".to_string()).await; - assert_eq!(by_len, ZValidatedAddress::Invalid); - assert_eq!(all_zeroes, ZValidatedAddress::Invalid); - } - - /// Build the `z_validate_address` rpc-call closure from `subscriber` and - /// run the shared validation suite. Factors the identical closure + - /// suite-call preamble shared by the four `z_validate_address` tests - /// (fetch_service zcashd/zebrad, state_service, json_server). - #[allow(deprecated)] - pub async fn run_z_validate_for(subscriber: &S) { - let rpc_call = - |addr: String| async move { subscriber.z_validate_address(addr).await.unwrap() }; - run_z_validate_suite(&rpc_call).await; + Ok(()) } } } diff --git a/live-tests/clientless/tests/chain_cache.rs b/live-tests/clientless/tests/chain_cache.rs index b680832a..ace4e87d 100644 --- a/live-tests/clientless/tests/chain_cache.rs +++ b/live-tests/clientless/tests/chain_cache.rs @@ -1,739 +1,326 @@ -use zaino_common::network::ActivationHeights; -use zaino_testutils::{Direct, Rpc, TestManager, ValidatorExt, ValidatorKind}; - -/// The validator's own `z_getsubtreesbyindex` answer, as `(root, end_height)` -/// pairs. -/// -/// Read from the raw JSON rather than through a Zaino type: this is the oracle -/// side, and deserializing it through the type under test would make the two -/// sides agree by construction on exactly the encoding being checked. -async fn oracle_subtree_roots( - oracle: &zaino_testutils::ValidatorOracle, - pool: &str, - start_index: u16, - max_entries: Option, -) -> Vec<([u8; 32], u32)> { - let mut params = vec![serde_json::json!(pool), serde_json::json!(start_index)]; - if let Some(limit) = max_entries { - params.push(serde_json::json!(limit)); - } +use std::time::Duration; - let response = oracle.call("z_getsubtreesbyindex", params).await; - response["subtrees"] - .as_array() - .expect("z_getsubtreesbyindex returns a subtrees array") - .iter() - .map(|subtree| { - let bytes = hex::decode( - subtree["root"] - .as_str() - .expect("subtree root from validator is a string"), - ) - .expect("subtree root from validator is not valid hex"); - let root: [u8; 32] = bytes - .as_slice() - .try_into() - .expect("received subtree root that is not 32 bytes"); - let end_height = subtree["end_height"] - .as_u64() - .expect("subtree end_height from validator is a number") - as u32; - (root, end_height) - }) - .collect() -} +use anyhow::{Context, Result}; +use rstest::rstest; +use serde_json::{json, Value}; +use ztest::prelude::*; -#[allow(deprecated)] -async fn create_test_manager_and_connector( - validator: &ValidatorKind, - activation_heights: Option, - chain_cache: Option, - enable_zaino: bool, - enable_clients: bool, -) -> (TestManager, zaino_testutils::ValidatorOracle) -where - T: ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let test_manager = TestManager::::launch( - validator, - None, - activation_heights, - chain_cache, - enable_zaino, - false, - enable_clients, - ) - .await - .unwrap(); - - let json_service = test_manager.full_node_jsonrpc_connector().await; - (test_manager, json_service) -} +const READY: Duration = Duration::from_secs(90); +/// One `/metrics` round trip, not a readiness budget +const SCRAPE: Duration = Duration::from_secs(10); -#[allow(deprecated)] mod chain_query_interface { - - use std::time::Duration; - - use futures::TryStreamExt as _; - use zaino_common::{CacheConfig, DatabaseConfig, ServiceConfig, StorageConfig}; - use zaino_state::{ - chain_index::{ - validator_source::ZebraValidatorSource, NodeBackedChainIndex, - NodeBackedChainIndexSubscriber, ShieldedPool, - }, - test_dependencies::{ - chain_index::{ChainIndex, ChainIndexRpcExt}, - ChainIndexConfig, - }, - Height, NodeBackedIndexerService, NodeBackedIndexerServiceConfig, ZcashService, - }; - #[cfg(feature = "zcashd_support")] - use zcash_local_net::validator::zcashd::Zcashd; - use zcash_local_net::validator::zebrad::Zebrad; - use zebra_chain::{ - parameters::{ - testnet::{ConfiguredActivationHeights, RegtestParameters}, - NetworkKind, - }, - serialization::ZcashDeserializeInto, - }; - use super::*; - #[allow(deprecated)] - async fn create_test_manager_and_chain_index( - validator: &ValidatorKind, - chain_cache: Option, - enable_zaino: bool, - enable_clients: bool, - ephemeral: bool, - ) -> ( - TestManager, - zaino_testutils::ValidatorOracle, - Option, - NodeBackedChainIndex, - NodeBackedChainIndexSubscriber, - ) - where - C: ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, - { - let (test_manager, json_service) = create_test_manager_and_connector::( - validator, - None, - chain_cache.clone(), - enable_zaino, - enable_clients, - ) - .await; - - match validator { - ValidatorKind::Zebrad => { - let state_chain_cache_dir = match chain_cache { - Some(dir) => dir, - None => test_manager.data_dir.clone(), - }; - let network = match test_manager.network { - NetworkKind::Regtest => { - let local_net_activation_heights = - test_manager.local_net.get_activation_heights().await; - - zebra_chain::parameters::Network::new_regtest(RegtestParameters::from( - ConfiguredActivationHeights { - before_overwinter: local_net_activation_heights.overwinter(), - overwinter: local_net_activation_heights.overwinter(), - sapling: local_net_activation_heights.sapling(), - blossom: local_net_activation_heights.blossom(), - heartwood: local_net_activation_heights.heartwood(), - canopy: local_net_activation_heights.canopy(), - nu5: local_net_activation_heights.nu5(), - nu6: local_net_activation_heights.nu6(), - nu6_1: local_net_activation_heights.nu6_1(), - nu6_2: local_net_activation_heights.nu6_2(), - nu6_3: local_net_activation_heights.nu6_3(), - nu7: local_net_activation_heights.nu7(), - }, - )) - } - - NetworkKind::Testnet => zebra_chain::parameters::Network::new_default_testnet(), - NetworkKind::Mainnet => zebra_chain::parameters::Network::Mainnet, - }; - // FIXME: when the direct connection is integrated into chain index this initialization must change - let state_service = - NodeBackedIndexerService::spawn(NodeBackedIndexerServiceConfig::new_direct( - zebra_state::Config { - cache_dir: state_chain_cache_dir, - ephemeral: false, - delete_old_database: true, - debug_stop_at_height: None, - debug_validity_check_interval: None, - // todo: does this matter? - should_backup_non_finalized_state: true, - debug_skip_non_finalized_state_backup_task: false, - }, - test_manager.full_node_rpc_listen_address.to_string(), - test_manager.full_node_grpc_listen_address, - false, - None, - None, - None, - ServiceConfig::default(), - StorageConfig { - cache: CacheConfig::default(), - database: DatabaseConfig { - path: test_manager - .data_dir - .as_path() - .to_path_buf() - .join("state-service-zaino"), - ..Default::default() - }, - }, - false, - network.into(), - None, - )) - .await - .unwrap(); - let config = ChainIndexConfig { - storage: StorageConfig { - database: DatabaseConfig { - path: test_manager - .data_dir - .as_path() - .to_path_buf() - .join("chain-index-zaino"), - ..Default::default() - }, - ..Default::default() - }, - ephemeral, - mempool: Default::default(), - db_version: 1, - // This fixture derives its runtime network from the - // heights the harness launched the validator with. - network: zaino_testutils::from_local_net_activation_heights( - &test_manager.local_net.get_activation_heights().await, - ) - .to_regtest_network(), - }; - - // **NOTE** The "fetch" backend is currently the backend used in the wild, and - // by zallet, although we want to push the community to transition to the - // "state" backend these tests using the "fetch" backend is currently useful - // for debugging bugs raised byt zallet devs. - let source = ZebraValidatorSource::rpc_only( - &test_manager.full_node_rpc_listen_address.to_string(), - Some(("xxxxxx".to_string(), "xxxxxx".to_string())), - config.network.clone(), - ) - .unwrap(); - let chain_index = NodeBackedChainIndex::new(source, config).await.unwrap(); - - let index_reader = chain_index.subscriber(); - tokio::time::sleep(Duration::from_secs(3)).await; - - ( - test_manager, - json_service, - Some(state_service), - chain_index, - index_reader, - ) - } - #[cfg(feature = "zcashd_support")] - ValidatorKind::Zcashd => { - let config = ChainIndexConfig { - storage: StorageConfig { - database: DatabaseConfig { - path: test_manager - .data_dir - .as_path() - .to_path_buf() - .join("chain-index-zaino"), - ..Default::default() - }, - ..Default::default() - }, - ephemeral, - mempool: Default::default(), - db_version: 1, - // This fixture derives its runtime network from the - // heights the harness launched the validator with. - network: zaino_testutils::from_local_net_activation_heights( - &test_manager.local_net.get_activation_heights().await, - ) - .to_regtest_network(), - }; - let source = ZebraValidatorSource::rpc_only( - &test_manager.full_node_rpc_listen_address.to_string(), - Some(("xxxxxx".to_string(), "xxxxxx".to_string())), - config.network.clone(), - ) - .unwrap(); - let chain_index = NodeBackedChainIndex::new(source, config).await.unwrap(); - let index_reader = chain_index.subscriber(); - tokio::time::sleep(Duration::from_secs(3)).await; - - (test_manager, json_service, None, chain_index, index_reader) - } - } - } - - // #[ignore = "prone to timeouts and hangs, to be fixed in chain index integration"] + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] - async fn get_block_range_zebrad() { - get_block_range::(&ValidatorKind::Zebrad).await - } + async fn get_block_range(#[case] validator: Validator) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; - #[cfg(feature = "zcashd_support")] - #[ignore = "prone to timeouts and hangs, to be fixed in chain index integration"] - #[tokio::test(flavor = "multi_thread")] - async fn get_block_range_zcashd() { - get_block_range::(&ValidatorKind::Zcashd).await - } + let tip = validator.generate_blocks(5).await?; + indexer.wait_for_block_num(tip, READY).await?; - async fn get_block_range(validator: &ValidatorKind) - where - C: ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, - { - let (test_manager, _json_service, _option_state_service, _chain_index, indexer) = - create_test_manager_and_chain_index::(validator, None, false, false, false) - .await; - - test_manager - .generate_blocks_and_wait_for_tip(5, &indexer) - .await; - let snapshot = indexer.snapshot_nonfinalized_state().await.unwrap(); + let tip_u32 = u32::from(validator.chain_height().await?); let range = indexer - .get_block_range(&snapshot, Height::try_from(0).unwrap(), None) - .unwrap() - .try_collect::>() - .await - .unwrap(); - for block in range { - block - .zcash_deserialize_into::() - .unwrap(); + .get_block_range(BlockHeight::from(1u32), BlockHeight::from(tip_u32)) + .await?; + assert_eq!( + range.len(), + tip_u32 as usize, + "get_block_range must serve every block over [1, tip]" + ); + for (offset, block) in range.iter().enumerate() { + assert_eq!( + block.height, + (offset + 1) as u64, + "blocks must be contiguous from height 1" + ); + assert_eq!(block.hash.len(), 32, "block hash must be 32 bytes"); } + Ok(()) } + /// Ephemeral mode opens no persistent finalised-state database: every read that + /// would need one is passed through to the validator instead. + /// + /// `db_height` is pinned at `0` in ephemeral mode, so the non-finalised cache + /// retains only `tip - MAX_NFS_DEPTH` (= seam + 10) upward. The chain is mined + /// past that so the reads below the floor are genuinely served by the passthrough + /// and not by the cache — at the operational seam of 1001 the whole chain fits in + /// the cache and the passthrough is never reached, hence the `fast-test-seam` image. + #[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] - async fn ephemeral_serves_finalised_blocks_zebrad() { - ephemeral_serves_finalised_blocks::(&ValidatorKind::Zebrad).await - } + async fn ephemeral_serves_finalised_blocks_zebrad() -> Result<()> { + const CHAIN_LEN: u32 = 160; + // seam (100) + the 10-block margin MAX_NFS_DEPTH adds + const CACHE_FLOOR: u32 = 160 - 110; + + let mut env = TestEnv::builder().ready_timeout(Duration::from_secs(180)); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest()); + let indexer = env.add_indexer( + dev!( + Indexer::Zainod, + "../../Dockerfile", + features = [ + "no_tls_use_unencrypted_traffic", + "allow_unencrypted_public_json_rpc_bind", + "prometheus", + "fast-test-seam", + ] + ) + .regtest() + .tuning(ZainoTuning::Ephemeral), + ); + env.build().await?; - /// Ephemeral mode on regtest: the chain index opens no persistent - /// finalised-state database and serves finalised reads straight from the - /// validator via the ephemeral passthrough. - /// - /// In ephemeral mode `db_height` is `0`, so the non-finalised cache retains - /// blocks only down to `tip - MAX_NFS_DEPTH` (a small margin past the seam). We - /// therefore generate well past that and query a height below it, so the reads are - /// genuinely served by the ephemeral *finalised* passthrough rather than the - /// non-finalised cache. The test then: - /// - fetches a finalised chain (indexed) block by height, re-fetches it by - /// its hash, and asserts the two are identical; - /// - streams compact blocks across the finalised / non-finalised boundary; - /// - asserts nothing was persisted to disk. - async fn ephemeral_serves_finalised_blocks(validator: &ValidatorKind) - where - C: ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, - { - use zaino_proto::proto::utils::PoolTypeFilter; - - let (test_manager, json_service, _option_state_service, _chain_index, indexer) = - create_test_manager_and_chain_index::(validator, None, false, false, true) - .await; - - // The finalised floor sits at `tip - seam`; the non-finalised cache retains a - // little past that. Generate well beyond it so low heights are evicted from the - // cache and served by the ephemeral finalised passthrough. `fast-test-seam` - // shrinks the seam to `FAST_TEST_MAX_NONFINALISED_DEPTH`, so a small chain suffices. - let seam = zaino_consensus::FAST_TEST_MAX_NONFINALISED_DEPTH; - test_manager - .generate_blocks_and_wait_for_tip(seam + 50, &indexer) - .await; - let snapshot = indexer.snapshot_nonfinalized_state().await.unwrap(); - let chain_height: u32 = json_service.get("getblockchaininfo").await["blocks"] - .as_u64() - .expect("a chain height") as u32; - - // `start_height` is comfortably below the retention window → evicted from the NFS - // cache, served by the passthrough; `end_height` is above the finalised floor - // (`tip - seam`) → non-finalised. - let start_height: u32 = chain_height - (seam + 20); - let end_height: u32 = chain_height - seam / 2; - let finalised_height = Height::try_from(start_height).unwrap(); - - // --- chain (indexed) block: fetch by height, then by its hash --- - let block_by_height = indexer - .get_indexed_block_by_height(&snapshot, &finalised_height) - .await - .unwrap() - .expect("ephemeral passthrough must serve a finalised chain block by height"); - let block_by_hash = indexer - .get_indexed_block_by_hash(&snapshot, block_by_height.hash()) - .await - .unwrap() - .expect("ephemeral passthrough must serve the same chain block by hash"); + let tip = validator.generate_blocks(CHAIN_LEN).await?; + indexer.wait_for_block_num(tip, READY).await?; + let tip = u32::from(tip); + + // Nothing persisted: `zaino_db_tip_height` is set on every batch commit of the v1 + // write path, which ephemeral mode never spawns, so the family never appears. assert_eq!( - block_by_height, block_by_hash, - "chain block fetched by height and by hash must be the same block" + indexer + .read(SCRAPE) + .await + .map_err(anyhow::Error::msg)? + .height_gauge(family("zaino_db_tip_height")), + None, + "ephemeral mode must open no finalised database, but the finalised writer \ + reported a committed tip" ); - // --- compact block stream across the finalised / non-finalised boundary --- - let stream = indexer - .get_compact_block_stream( - &snapshot, - Height::try_from(start_height).unwrap(), - Height::try_from(end_height).unwrap(), - PoolTypeFilter::includes_all(), - ) - .await - .unwrap() - .expect("ephemeral mode must serve a compact block stream across the boundary"); - let streamed = stream.try_collect::>().await.unwrap(); - - let expected_count = (end_height - start_height + 1) as usize; + let range = indexer + .get_block_range(BlockHeight::from(1u32), BlockHeight::from(tip)) + .await?; assert_eq!( - streamed.len(), - expected_count, - "stream must cover the full inclusive range across the finalised boundary" + range.len(), + tip as usize, + "ephemeral index must serve every block over [1, {tip}]" ); - for (offset, compact_block) in streamed.iter().enumerate() { - let streamed_height = u32::try_from(compact_block.height).unwrap(); - assert_eq!(streamed_height, start_height + offset as u32); + for (offset, block) in range.iter().enumerate() { + assert_eq!( + block.height, + (offset + 1) as u64, + "served blocks must be contiguous from height 1" + ); + } + for pair in range.windows(2) { + assert_eq!( + pair[1].prev_hash, pair[0].hash, + "block {} does not link to block {} — the passthrough and the cache served two different chains", + pair[1].height, pair[0].height + ); } - // Ephemeral mode must persist nothing: no chain-index database directory. - let chain_index_db_dir = test_manager.data_dir.as_path().join("chain-index-zaino"); - assert!( - !chain_index_db_dir.exists(), - "ephemeral mode must not create a persistent chain-index database at \ - {chain_index_db_dir:?}" + let below_floor = BlockHeight::from(CACHE_FLOOR / 2); + let by_height = indexer.get_block(below_floor).await?; + let hash_bytes: [u8; 32] = by_height + .hash + .clone() + .try_into() + .ok() + .context("served block hash must be 32 bytes")?; + let by_hash = indexer.get_block_by_hash(BlockHash(hash_bytes)).await?; + assert_eq!( + by_hash, by_height, + "a block below the cache floor must be the same fetched by height and by hash" ); - } - #[ignore = "prone to timeouts and hangs, to be fixed in chain index integration"] - #[tokio::test(flavor = "multi_thread")] - async fn sync_large_chain_zebrad() { - sync_large_chain::(&ValidatorKind::Zebrad).await + let vrpc = validator.json_rpc().await?; + let hash = vrpc + .call_value("getblockhash", json!([by_height.height])) + .await?; + let mut served = by_height.hash.clone(); + served.reverse(); + assert_eq!( + zaino_testutils::hex::encode(&served), + hash.as_str().context("getblockhash returns a hex string")?, + "the passthrough served a different block than the validator holds at {}", + by_height.height + ); + Ok(()) } - #[cfg(feature = "zcashd_support")] - #[ignore = "prone to timeouts and hangs, to be fixed in chain index integration"] - #[tokio::test(flavor = "multi_thread")] - async fn sync_large_chain_zcashd() { - sync_large_chain::(&ValidatorKind::Zcashd).await + fn validator_subtrees(reply: &Value) -> Result, u64)>> { + reply + .get("subtrees") + .and_then(Value::as_array) + .context("z_getsubtreesbyindex reply missing `subtrees` array")? + .iter() + .map(|subtree| { + let root_hex = subtree + .get("root") + .and_then(Value::as_str) + .context("subtree root from validator is not a string")?; + let bytes = zaino_testutils::hex::decode(root_hex, "z_getsubtreesbyindex root")?; + let end_height = subtree + .get("end_height") + .and_then(Value::as_u64) + .context("subtree missing end_height")?; + Ok((bytes, end_height)) + }) + .collect() } - async fn sync_large_chain(validator: &ValidatorKind) - where - C: ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, - { - let (test_manager, json_service, option_state_service, _chain_index, indexer) = - create_test_manager_and_chain_index::(validator, None, false, false, false) - .await; - - test_manager - .generate_blocks_and_wait_for_tip(5, &indexer) - .await; - if let Some(state_service) = option_state_service.as_ref() { - test_manager - .generate_blocks_and_wait_for_tip(0, state_service.get_subscriber().inner_ref()) - .await - } - - test_manager - .generate_blocks_and_wait_for_tip(150, &indexer) - .await; - if let Some(state_service) = option_state_service.as_ref() { - test_manager - .generate_blocks_and_wait_for_tip(0, state_service.get_subscriber().inner_ref()) - .await; - } - - tokio::time::sleep(std::time::Duration::from_millis(5000)).await; - - let snapshot = indexer.snapshot_nonfinalized_state().await.unwrap(); - let chain_height = json_service.get("getblockchaininfo").await["blocks"] - .as_u64() - .expect("a chain height") as u32; - - // Finalised floor is `tip - seam`; pick a range straddling it. - let seam = zaino_consensus::FAST_TEST_MAX_NONFINALISED_DEPTH; - let finalised_start = Height::try_from(chain_height - (seam + 50)).unwrap(); - let finalised_tip = Height::try_from(chain_height - seam).unwrap(); - let end = Height::try_from(chain_height - seam / 2).unwrap(); - - let finalized_blocks = indexer - .get_block_range(&snapshot, finalised_start, Some(finalised_tip)) - .unwrap() - .try_collect::>() - .await - .unwrap(); - for block in finalized_blocks { - block - .zcash_deserialize_into::() - .unwrap(); - } - - let non_finalised_blocks = indexer - .get_block_range(&snapshot, finalised_tip, Some(end)) - .unwrap() - .try_collect::>() - .await - .unwrap(); - for block in non_finalised_blocks { - block - .zcash_deserialize_into::() - .unwrap(); - } + fn indexer_subtrees(roots: &[SubtreeRoot]) -> Vec<(Vec, u64)> { + roots + .iter() + .map(|r| (r.root_hash.clone(), r.completing_block_height)) + .collect() } - // #[ignore = "prone to timeouts and hangs, to be fixed in chain index integration"] + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] - async fn get_subtree_roots_zebrad() { - get_subtree_roots::(&ValidatorKind::Zebrad).await - } + async fn get_subtree_roots(#[case] validator: Validator) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; - #[cfg(feature = "zcashd_support")] - #[ignore = "prone to timeouts and hangs, to be fixed in chain index integration"] - #[tokio::test(flavor = "multi_thread")] - async fn get_subtree_roots_zcashd() { - get_subtree_roots::(&ValidatorKind::Zcashd).await - } + let tip = validator.generate_blocks(5).await?; + indexer.wait_for_block_num(tip, READY).await?; - async fn get_subtree_roots(validator: &ValidatorKind) - where - C: ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, - { - let (test_manager, json_service, _option_state_service, _chain_index, indexer) = - create_test_manager_and_chain_index::(validator, None, false, false, false) - .await; - - test_manager - .generate_blocks_and_wait_for_tip(5, &indexer) - .await; + let vrpc = validator.json_rpc().await?; - let test_pools = [ShieldedPool::Sapling, ShieldedPool::Orchard]; - let valid_start_index = 0; - let max_entries = Some(0); + let test_pools = [ + ("sapling", ShieldedProtocol::Sapling), + ("orchard", ShieldedProtocol::Orchard), + ]; + let valid_start_index: u32 = 0; + let max_entries: u32 = 0; // *** Test valid requests *** - - for pool in test_pools.clone() { - let valid_chain_index_subtree_roots_response = indexer - .get_subtree_roots(pool.clone(), valid_start_index, max_entries) - .await - .unwrap(); - - let formatted_valid_validator_subtree_roots = super::oracle_subtree_roots( - &json_service, - &pool.pool_string(), - valid_start_index, - max_entries, - ) - .await; - + for (pool_string, protocol) in test_pools { + let indexer_roots = indexer + .get_subtree_roots(valid_start_index, protocol, max_entries) + .await?; + let validator_reply = vrpc + .call_value( + "z_getsubtreesbyindex", + json!([pool_string, valid_start_index, max_entries]), + ) + .await?; assert_eq!( - valid_chain_index_subtree_roots_response, - formatted_valid_validator_subtree_roots + indexer_subtrees(&indexer_roots), + validator_subtrees(&validator_reply)?, + "chain-index subtree roots must match validator for pool {pool_string}" ); } // *** Test invalid requests *** - - let invalid_start_index = 10000; - - let valid_chain_index_subtree_roots_response = indexer - .get_subtree_roots(test_pools[1].clone(), invalid_start_index, max_entries) - .await - .unwrap(); - - let formatted_valid_validator_subtree_roots = super::oracle_subtree_roots( - &json_service, - &test_pools[1].pool_string(), - invalid_start_index, - max_entries, - ) - .await; - + let invalid_start_index: u32 = 10000; + let (orchard_string, orchard_protocol) = test_pools[1]; + let indexer_roots = indexer + .get_subtree_roots(invalid_start_index, orchard_protocol, max_entries) + .await?; + let validator_reply = vrpc + .call_value( + "z_getsubtreesbyindex", + json!([orchard_string, invalid_start_index, max_entries]), + ) + .await?; assert_eq!( - valid_chain_index_subtree_roots_response, - formatted_valid_validator_subtree_roots + indexer_subtrees(&indexer_roots), + validator_subtrees(&validator_reply)?, + "chain-index subtree roots must match validator at invalid start index" ); + Ok(()) } - // #[ignore = "prone to timeouts and hangs, to be fixed in chain index integration"] + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] - async fn get_mempool_stream_fresh_snapshot_repeated_zebrad() { - get_mempool_stream_fresh_snapshot_repeated::(&ValidatorKind::Zebrad).await - } + async fn get_mempool_stream_fresh_snapshot_repeated( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; - #[cfg(feature = "zcashd_support")] - #[ignore = "prone to timeouts and hangs, to be fixed in chain index integration"] - #[tokio::test(flavor = "multi_thread")] - async fn get_mempool_stream_fresh_snapshot_repeated_zcashd() { - get_mempool_stream_fresh_snapshot_repeated::(&ValidatorKind::Zcashd).await - } - - async fn get_mempool_stream_fresh_snapshot_repeated(validator: &ValidatorKind) - where - C: ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, - { - use futures::StreamExt as _; - use tokio::time::{timeout, Duration}; - - let (test_manager, _json_service, _option_state_service, _chain_index, indexer) = - create_test_manager_and_chain_index::(validator, None, false, false, false) - .await; - - test_manager - .generate_blocks_and_wait_for_tip(5, &indexer) - .await; + let tip = validator.generate_blocks(5).await?; + indexer.wait_for_block_num(tip, READY).await?; for iteration in 0..5 { - let snapshot = indexer.snapshot_nonfinalized_state().await.unwrap(); - tokio::time::sleep(Duration::from_millis(500)).await; - let mempool_stream = indexer - .get_mempool_stream(Some(&snapshot)) - .unwrap_or_else(|| { - panic!("fresh snapshot unexpectedly returned None on iteration {iteration}") - }); - let mut mempool_stream = std::pin::pin!(mempool_stream); - - test_manager - .generate_blocks_and_wait_for_tip(1, &indexer) - .await; - - timeout(Duration::from_secs(20), async { - while let Some(item) = mempool_stream.next().await { - item.expect("mempool stream yielded unexpected error"); - } + let stream = indexer.get_mempool_stream(); + let mine = async { + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await + }; + + let joined = tokio::time::timeout(Duration::from_secs(20), async { + tokio::join!(stream, mine) }) - .await - .expect("mempool stream did not close after chain tip changed"); + .await; + let (stream_result, mine_result) = joined.unwrap_or_else(|_| { + panic!( + "mempool stream did not close after chain tip changed on iteration {iteration}" + ) + }); + stream_result.with_context(|| { + format!("mempool stream yielded unexpected error on iteration {iteration}") + })?; + mine_result?; } + Ok(()) } - // #[ignore = "prone to timeouts and hangs, to be fixed in chain index integration"] + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] - async fn zallet_like_steady_state_loop_zebrad() { - zallet_like_steady_state_loop::(&ValidatorKind::Zebrad).await - } - - #[cfg(feature = "zcashd_support")] - #[ignore = "prone to timeouts and hangs, to be fixed in chain index integration"] - #[tokio::test(flavor = "multi_thread")] - async fn zallet_like_steady_state_loop_zcashd() { - zallet_like_steady_state_loop::(&ValidatorKind::Zcashd).await - } - - async fn zallet_like_steady_state_loop(validator: &ValidatorKind) - where - C: ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, - { - use futures::{StreamExt as _, TryStreamExt as _}; - use tokio::time::{timeout, Duration}; - - let (test_manager, _json_service, _option_state_service, _chain_index, indexer) = - create_test_manager_and_chain_index::(validator, None, false, false, false) - .await; - - test_manager - .generate_blocks_and_wait_for_tip(5, &indexer) - .await; + async fn zallet_like_steady_state_loop( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; - let initial_snapshot = indexer.snapshot_nonfinalized_state().await.unwrap(); - let mut prev_tip = indexer.best_chaintip(&initial_snapshot).await.unwrap(); + let mut prev_tip = validator.generate_blocks(5).await?; + indexer.wait_for_block_num(prev_tip, READY).await?; for iteration in 0..5 { - let snapshot = indexer.snapshot_nonfinalized_state().await.unwrap(); - let current_tip = indexer.best_chaintip(&snapshot).await.unwrap(); - - let fork_point = indexer - .find_fork_point(&snapshot, &prev_tip.hash) - .await - .unwrap() - .unwrap_or_else(|| { - panic!( - "no fork point found on iteration {iteration}: prev_tip=({:?}, {:?}) current_tip=({:?}, {:?})", - prev_tip.height, - prev_tip.hash, - current_tip.height, - current_tip.hash, - ) - }); + let current_tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(current_tip, READY).await?; - assert!( - fork_point.1 <= current_tip.height, - "fork point height {:?} above current tip {:?} on iteration {iteration}", - fork_point.1, - current_tip.height, + assert_eq!( + indexer.latest_block_height().await?, + current_tip, + "indexer must track the advancing tip on iteration {iteration}" ); - if fork_point.1 < current_tip.height { - let start_height = fork_point.1 + 1; - let end_height = Some(current_tip.height); - - let blocks_to_apply = indexer - .get_block_range(&snapshot, start_height, end_height) - .unwrap_or_else(|| { - panic!( - "expected block range on iteration {iteration}: start={:?} end={:?}", - start_height, end_height, - ) - }); - - let applied_blocks = blocks_to_apply.try_collect::>().await.unwrap(); - - let expected_count = u32::from(current_tip.height) - u32::from(fork_point.1); - + let prev = u32::from(prev_tip); + let current = u32::from(current_tip); + let applied = indexer + .get_block_range(BlockHeight::from(prev + 1), BlockHeight::from(current)) + .await?; + assert_eq!( + applied.len(), + (current - prev) as usize, + "indexer must serve exactly the newly mined blocks on iteration {iteration}" + ); + for (offset, block) in applied.iter().enumerate() { assert_eq!( - applied_blocks.len(), - expected_count as usize, - "unexpected number of applied blocks on iteration {iteration}", + block.height, + (prev + 1 + offset as u32) as u64, + "applied blocks must be contiguous on iteration {iteration}" ); } - let mempool_stream = indexer - .get_mempool_stream(Some(&snapshot)) - .unwrap_or_else(|| { - panic!( - "fresh snapshot unexpectedly returned None on iteration {iteration}: \ - current tip height={:?} hash={:?}, \ - prev_tip height={:?} hash={:?}", - current_tip.height, current_tip.hash, prev_tip.height, prev_tip.hash, - ) - }); - let mut mempool_stream = std::pin::pin!(mempool_stream); - - test_manager - .generate_blocks_and_wait_for_tip(1, &indexer) - .await; - - timeout(Duration::from_secs(20), async { - while let Some(item) = mempool_stream.next().await { - item.expect("mempool stream yielded unexpected error"); - } - }) - .await - .expect("mempool stream did not close after chain tip changed"); - prev_tip = current_tip; } + Ok(()) } } diff --git a/live-tests/clientless/tests/compact_block_consistency.rs b/live-tests/clientless/tests/compact_block_consistency.rs index 52e31dc7..6931eb90 100644 --- a/live-tests/clientless/tests/compact_block_consistency.rs +++ b/live-tests/clientless/tests/compact_block_consistency.rs @@ -8,82 +8,111 @@ //! block, per pool, for the request shape real (including pre-Ironwood) light clients //! send: an empty `poolTypes` filter. -use zaino_common::network::ActivationHeights; -#[allow(deprecated)] -use zaino_state::ZcashIndexer as _; -use zaino_testutils::{ - MinerPool, Rpc, TestManager, ValidatorKind, IRONWOOD_ONLY_ACTIVATION_HEIGHTS, - NU6_3_TRANSITION_BOUNDARY, ORCHARD_ONLY_ACTIVATION_HEIGHTS, - ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS, -}; -use zcash_local_net::validator::zebrad::Zebrad; -use zebra_chain::serialization::ZcashDeserialize as _; - -/// multi_thread required: the test manager spawns the validator and indexer services. -#[allow(deprecated)] +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde_json::{json, Value}; +use zaino_testutils::legacy_parser::block::FullBlock; +use ztest::prelude::*; + +const READY: Duration = Duration::from_secs(120); + +/// The mid-chain NU6.3 (Ironwood) activation height for the transition fixture: +/// an Orchard era `[2, 6)` that flips to Ironwood at height 6. +const NU6_3_TRANSITION_BOUNDARY: u32 = 6; + +/// The pool a coinbase reward lands in. One per network-upgrade era, and the +/// transaction version that carries it. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum CoinbaseEra { + Sapling, + Orchard, + Ironwood, +} + +#[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] -async fn unfiltered_compact_blocks_match_chain_metadata_zebrad() { - let mut test_manager = TestManager::::launch_mining_to( - // Shielded mining: from NU6.3 an orchard-receiver coinbase is built as Ironwood - // actions (the coinbase's Orchard component must be empty from NU6.3), so every - // generated block carries ironwood data for the walk to check. A transparent - // miner would leave the ironwood assertions vacuous. - MinerPool::Orchard, - &ValidatorKind::Zebrad, - None, - Some(IRONWOOD_ONLY_ACTIVATION_HEIGHTS), - None, - true, - false, - false, - ) - .await - .unwrap(); - let subscriber = test_manager.subscriber().clone(); - - test_manager - .generate_blocks_and_wait_for_tip(8, &subscriber) - .await; - let tip = u64::from(subscriber.chain_height().await.unwrap().0); +async fn unfiltered_compact_blocks_match_chain_metadata_zebrad() -> Result<()> { + // Shielded mining: from NU6.3 an orchard-receiver coinbase is built as Ironwood + // actions (the coinbase's Orchard component must be empty from NU6.3), so every + // generated block carries ironwood data for the walk to check. A transparent + // miner would leave the ironwood assertions vacuous. + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(Pool::Orchard)); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(8).await?; + indexer.wait_for_block_num(tip, READY).await?; + + // The independent oracle: zebrad's own per-block `(sapling, orchard, ironwood)` tree + // sizes, computed without reference to what zaino serves. A pool's key is omitted + // when its tree is empty, so a missing key reads as size 0. + let vrpc = validator.json_rpc().await?; + let mut oracle: Vec<(u64, u64, u64)> = Vec::new(); + for height in 0..=u64::from(tip) { + let block = vrpc + .call_value("getblock", json!([height.to_string(), 1])) + .await?; + let trees = block + .get("trees") + .context("verbose getblock must carry a trees field")?; + let size = |pool: &str| { + trees + .get(pool) + .and_then(|t| t.get("size")) + .and_then(Value::as_u64) + .unwrap_or(0) + }; + oracle.push((size("sapling"), size("orchard"), size("ironwood"))); + } // The empty pool filter is what unfiltered (pre-Ironwood) clients send; the served // stream must include every shielded pool's actions. - let blocks = zaino_testutils::collect_block_range(&subscriber, 0, tip, vec![]).await; - assert!(!blocks.is_empty(), "no compact blocks served"); - - let connector = test_manager.full_node_jsonrpc_connector().await; + let start = BlockHeight::from(1u32); + let blocks = indexer.get_block_range(start, tip).await?; + assert_eq!( + blocks.len() as u64, + u64::from(tip), + "the served range must cover every height in [1, {tip}]" + ); - let (mut prev_sapling, mut prev_orchard, mut prev_ironwood) = (0u32, 0u32, 0u32); - let mut total_orchard_actions = 0usize; - let mut total_ironwood_actions = 0usize; + let (mut prev_sapling, mut prev_orchard, mut prev_ironwood) = oracle[0]; + let mut total_orchard_actions = 0u64; + let mut total_ironwood_actions = 0u64; for (index, block) in blocks.iter().enumerate() { assert_eq!( - block.height, index as u64, - "served blocks must be contiguous from genesis for the walk's running totals" + block.height, + index as u64 + 1, + "served blocks must be contiguous for the walk's running totals" ); let metadata = block .chain_metadata .as_ref() - .expect("every served compact block carries chain metadata"); + .context("every served compact block carries chain metadata")?; - let sapling_outputs: u32 = block.vtx.iter().map(|tx| tx.outputs.len() as u32).sum(); - let orchard_actions: u32 = block.vtx.iter().map(|tx| tx.actions.len() as u32).sum(); - let ironwood_actions: u32 = block + let sapling_outputs: u64 = block.vtx.iter().map(|tx| tx.outputs.len() as u64).sum(); + let orchard_actions: u64 = block.vtx.iter().map(|tx| tx.actions.len() as u64).sum(); + let ironwood_actions: u64 = block .vtx .iter() - .map(|tx| tx.ironwood_actions.len() as u32) + .map(|tx| tx.ironwood_actions.len() as u64) .sum(); - total_orchard_actions += orchard_actions as usize; - total_ironwood_actions += ironwood_actions as usize; + total_orchard_actions += orchard_actions; + total_ironwood_actions += ironwood_actions; + + let sapling_size = u64::from(metadata.sapling_commitment_tree_size); + let orchard_size = u64::from(metadata.orchard_commitment_tree_size); + let ironwood_size = u64::from(metadata.ironwood_commitment_tree_size); assert_eq!( - metadata.sapling_commitment_tree_size, + sapling_size, prev_sapling + sapling_outputs, "sapling tree-size delta must equal the served output count at height {}", block.height ); assert_eq!( - metadata.orchard_commitment_tree_size, + orchard_size, prev_orchard + orchard_actions, "orchard tree-size delta must equal the served action count at height {}", block.height @@ -92,55 +121,35 @@ async fn unfiltered_compact_blocks_match_chain_metadata_zebrad() { // commitments from actions the block omits (e.g. ironwood stripped from an // unfiltered request) reads to a scanning wallet as a phantom chain reorg. assert_eq!( - metadata.ironwood_commitment_tree_size, + ironwood_size, prev_ironwood + ironwood_actions, "ironwood tree-size delta must equal the served action count at height {}", block.height ); - // Clientless-exclusive predicate — oracle parity: zebrad's verbose getblock - // reports the validator's own per-block tree sizes, an independent - // implementation's answer to compare zaino's served metadata against. Package - // tests cannot express this: their "source of truth" is the object being - // served, so any such comparison is circular. - // Verbosity 1: txids-as-strings plus the `trees` field the oracle needs - // (verbosity 2 returns full transaction objects, which BlockObject's - // string-typed `tx` field rejects). - let oracle_trees = connector - .call( - "getblock", - vec![ - serde_json::json!(block.height.to_string()), - serde_json::json!(1), - ], - ) - .await["trees"] - .clone(); - // Zebra omits a pool's entry entirely while its tree is empty, so an - // absent entry is a size of zero rather than a missing answer. - let oracle_tree_size = |pool: &str| oracle_trees[pool]["size"].as_u64().unwrap_or(0); + // Clientless-exclusive predicate — oracle parity. Package tests cannot express + // this: their "source of truth" is the object being served, so any such + // comparison is circular. + let (oracle_sapling, oracle_orchard, oracle_ironwood) = oracle[block.height as usize]; assert_eq!( - u64::from(metadata.sapling_commitment_tree_size), - oracle_tree_size("sapling"), + sapling_size, oracle_sapling, "served sapling tree size must match the validator's own at height {}", block.height ); assert_eq!( - u64::from(metadata.orchard_commitment_tree_size), - oracle_tree_size("orchard"), + orchard_size, oracle_orchard, "served orchard tree size must match the validator's own at height {}", block.height ); assert_eq!( - u64::from(metadata.ironwood_commitment_tree_size), - oracle_tree_size("ironwood"), + ironwood_size, oracle_ironwood, "served ironwood tree size must match the validator's own at height {}", block.height ); - prev_sapling = metadata.sapling_commitment_tree_size; - prev_orchard = metadata.orchard_commitment_tree_size; - prev_ironwood = metadata.ironwood_commitment_tree_size; + prev_sapling = sapling_size; + prev_orchard = orchard_size; + prev_ironwood = ironwood_size; } assert!( @@ -158,226 +167,311 @@ async fn unfiltered_compact_blocks_match_chain_metadata_zebrad() { "an Orchard-receiver coinbase must carry no Orchard actions from NU6.3" ); - test_manager.close().await; + Ok(()) } -/// The shielded pool a miner's coinbase routes the reward to: Orchard in the -/// NU5-through-NU6.2 era (transaction v5), Ironwood from NU6.3 (v6). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum CoinbaseRewardPool { - Orchard, - Ironwood, -} +/// Orchard-only era: NU6.3 never activates, so every post-NU5 coinbase stays an +/// Orchard coinbase and no ironwood ever appears. +#[ztest::qos::integration] +#[tokio::test(flavor = "multi_thread")] +async fn orchard_only_coinbase_routing_zebrad() -> Result<()> { + // The zebrad defaults are now the canonical NU6.3-at-2 set, so this fixture is explicit. + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .build(), + ); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(Pool::Orchard)); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; -/// Shared body of the class-1 (consensus) coinbase predicates: the first -/// transaction is a coinbase of the era's version, carries the reward as at least -/// one action in `pool`, and has an empty sapling component and an empty -/// other-shielded-pool component. -/// -/// The action count uses `>= 1` rather than the padded exact count so the predicate -/// does not couple to the Orchard bundle-padding rule. -fn is_valid_shielded_coinbase(block: &zebra_chain::block::Block, pool: CoinbaseRewardPool) -> bool { - let Some(coinbase) = block.transactions.first() else { - return false; - }; - let orchard_actions = coinbase.orchard_actions().count(); - let ironwood_actions = coinbase.ironwood_actions().count(); - let (version, rewarded_pool_actions, other_pool_actions) = match pool { - CoinbaseRewardPool::Orchard => (5, orchard_actions, ironwood_actions), - CoinbaseRewardPool::Ironwood => (6, ironwood_actions, orchard_actions), - }; - coinbase.is_coinbase() - && coinbase.version() == version - && coinbase.sapling_outputs().count() == 0 - && rewarded_pool_actions >= 1 - && other_pool_actions == 0 -} + let tip = validator.generate_blocks(6).await?; + indexer.wait_for_block_num(tip, READY).await?; -/// Class-1 (consensus) predicate: in the NU5-through-NU6.2 era, a shielded -/// (orchard-receiver) miner's coinbase carries the reward as Orchard actions. -fn is_valid_orchard_coinbase(block: &zebra_chain::block::Block) -> bool { - is_valid_shielded_coinbase(block, CoinbaseRewardPool::Orchard) -} + let blocks = indexer + .get_block_range(BlockHeight::from(1u32), tip) + .await?; + assert_eq!( + blocks.len() as u64, + u64::from(tip), + "the served range must cover every height in [1, {tip}]" + ); -/// Class-1 (consensus) predicate: from NU6.3 the same miner's coinbase must have an -/// empty Orchard component, with the reward routed to Ironwood actions instead. -/// -/// Known open question at the activation boundary: the first NU6.3 block's coinbase -/// has been observed served as Orchard (one action, zero ironwood) — see -/// for the hypotheses (zebrad -/// builder off-by-one vs missing routing vs a zaino pool-swap). This predicate over -/// raw validator blocks is that issue's disambiguator. -fn is_valid_ironwood_coinbase(block: &zebra_chain::block::Block) -> bool { - is_valid_shielded_coinbase(block, CoinbaseRewardPool::Ironwood) -} + // Two independent halves per height, collected across the whole chain so one run + // separates them: the raw-block predicate is class 1 (zebrad's own consensus + // routing, zaino uninvolved), the raw-vs-served comparison is class 2 (zaino's + // pool routing on the wire). + let vrpc = validator.json_rpc().await?; + let mut violations: Vec = Vec::new(); + for (index, served) in blocks.iter().enumerate() { + let height = index as u64 + 1; + assert_eq!(served.height, height, "served blocks must be contiguous"); -/// One-line coinbase summary for assertion messages, so a predicate failure names the -/// violated clause instead of reporting a bare boolean. -fn describe_coinbase(block: &zebra_chain::block::Block) -> String { - match block.transactions.first() { - Some(coinbase) => format!( - "coinbase: v{}, sapling outputs {}, orchard actions {}, ironwood actions {}, txs in block {}", - coinbase.version(), - coinbase.sapling_outputs().count(), - coinbase.orchard_actions().count(), - coinbase.ironwood_actions().count(), - block.transactions.len(), - ), - None => "block has no transactions".to_string(), - } -} + let raw = vrpc + .call_value("getblock", json!([height.to_string(), 0])) + .await?; + let raw = zaino_testutils::hex::decode( + raw.as_str() + .context("verbosity-0 getblock returns a hex string")?, + "getblock verbosity 0", + )?; + let coinbase = FullBlock::parse_from_hex(&raw, None)? + .transactions() + .into_iter() + .next() + .context("every block carries a coinbase transaction")?; -/// Which shielded pool the fixture's coinbase reward must land in at a given height. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum CoinbaseEra { - /// Below NU5: the shielded-receiver reward cannot land in either pool. - Neither, - /// NU5 through NU6.2: the orchard-receiver reward is paid as Orchard actions. - Orchard, - /// From NU6.3: the same receiver's reward is routed to Ironwood actions. - Ironwood, -} + // A coinbase input is the single null prevout (all-zero hash, index u32::MAX). + let inputs = coinbase.transparent_inputs(); + let is_coinbase = matches!(inputs.as_slice(), [(prevout, u32::MAX, _)] if prevout.iter().all(|b| *b == 0)); + let version = coinbase.version(); + let sapling = coinbase.shielded_outputs().len(); + let orchard = coinbase.orchard_actions().len(); + let ironwood = coinbase.ironwood_actions().len(); -/// Launches an orchard-receiver mining fixture on `activation_heights`, generates -/// `blocks` blocks, and asserts each height's raw validator block satisfies exactly -/// the era predicate `expected_era` assigns it. Raw blocks come straight from the -/// validator — zaino runs (the harness needs its subscriber as the block-generation -/// pollable) but is never consulted, so a failure here is a class-1 -/// (consensus/routing) or fixture fact, never a zaino one. -/// -/// Mismatches are collected across the whole chain and reported together rather than -/// aborting at the first failing height, so one run distinguishes the hypotheses of -/// : a boundary-only mismatch is the -/// zebrad builder off-by-one (A1), every-post-activation-height mismatches mean the -/// routing is absent (A2), and a clean pass here while the e2e wire tests fail moves -/// the blame to a zaino pool-swap (B). -async fn assert_coinbase_routing( - activation_heights: ActivationHeights, - blocks: u32, - expected_era: impl Fn(u64) -> CoinbaseEra, -) { - #[allow(deprecated)] - let mut test_manager = TestManager::::launch_mining_to( - MinerPool::Orchard, - &ValidatorKind::Zebrad, - None, - Some(activation_heights), - None, - true, - false, - false, - ) - .await - .unwrap(); - let subscriber = test_manager.subscriber().clone(); - - test_manager - .generate_blocks_and_wait_for_tip(blocks, &subscriber) - .await; - let tip = u64::from(subscriber.chain_height().await.unwrap().0); - - let connector = test_manager.full_node_jsonrpc_connector().await; - let mut violations: Vec = Vec::new(); - for height in 0..=tip { - // Verbosity 0 is the serialised block as a hex string. - let raw = connector - .call( - "getblock", - vec![serde_json::json!(height.to_string()), serde_json::json!(0)], - ) - .await; - let raw = hex::decode( - raw.as_str() - .expect("verbosity-0 getblock returns a hex string"), - ) - .expect("verbosity-0 getblock returns valid hex"); - let block = zebra_chain::block::Block::zcash_deserialize(raw.as_slice()) - .expect("validator serves deserializable blocks"); - - let expected = expected_era(height); - let (want_orchard, want_ironwood) = match expected { - CoinbaseEra::Neither => (false, false), - CoinbaseEra::Orchard => (true, false), - CoinbaseEra::Ironwood => (false, true), + // The reward lands in exactly one pool; anything else is `None` and fails. + let observed = match (version, sapling, orchard, ironwood) { + (4, 1.., 0, 0) => Some(CoinbaseEra::Sapling), + (5, 0, 1.., 0) => Some(CoinbaseEra::Orchard), + (6, 0, 0, 1..) => Some(CoinbaseEra::Ironwood), + _ => None, + }; + + let expected = match height { + h if h >= 2 => CoinbaseEra::Orchard, + _ => CoinbaseEra::Sapling, }; - let got_orchard = is_valid_orchard_coinbase(&block); - let got_ironwood = is_valid_ironwood_coinbase(&block); - if got_orchard != want_orchard || got_ironwood != want_ironwood { + + let served_orchard: usize = served.vtx.iter().map(|tx| tx.actions.len()).sum(); + let served_ironwood: usize = served.vtx.iter().map(|tx| tx.ironwood_actions.len()).sum(); + let wire_ok = served_orchard == orchard && served_ironwood == ironwood; + + if !is_coinbase || observed != Some(expected) || !wire_ok { violations.push(format!( - "height {height}: expected {expected:?}, predicates say \ - (orchard: {got_orchard}, ironwood: {got_ironwood}) — {}", - describe_coinbase(&block) + "height {height}: want {expected:?}, raw is {observed:?} \ + (is_coinbase {is_coinbase}, v{version}, sapling {sapling}, \ + orchard {orchard}, ironwood {ironwood}); \ + zaino served orchard {served_orchard}, ironwood {served_ironwood}" )); } } - assert!( violations.is_empty(), - "coinbase routing mismatches ({} of {} heights; see \ - https://github.com/zingolabs/zaino/issues/1368 for the hypothesis map):\n{}", + "coinbase routing mismatches ({} of {} heights):\n{}", violations.len(), - tip + 1, + blocks.len(), violations.join("\n") ); - test_manager.close().await; -} - -/// Orchard-only era: NU6.3 never activates, so every post-NU5 coinbase stays an -/// Orchard coinbase and no ironwood ever appears. (The zebrad *default* heights are -/// now the canonical NU6.3-at-2 set, so this fixture is explicit.) -/// -/// multi_thread required: the test manager spawns the validator and indexer services. -#[tokio::test(flavor = "multi_thread")] -async fn orchard_only_coinbase_routing_zebrad() { - assert_coinbase_routing(ORCHARD_ONLY_ACTIVATION_HEIGHTS, 6, |height| { - if height >= 2 { - CoinbaseEra::Orchard - } else { - CoinbaseEra::Neither - } - }) - .await; + Ok(()) } /// Ironwood-only era: NU6.3 active from height 2 (with every prior upgrade), so every /// post-activation coinbase is an Ironwood coinbase and no Orchard coinbase ever /// appears. -/// -/// multi_thread required: the test manager spawns the validator and indexer services. +#[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] -async fn ironwood_only_coinbase_routing_zebrad() { - assert_coinbase_routing(IRONWOOD_ONLY_ACTIVATION_HEIGHTS, 6, |height| { - if height >= 2 { - CoinbaseEra::Ironwood - } else { - CoinbaseEra::Neither +async fn ironwood_only_coinbase_routing_zebrad() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(Pool::Orchard)); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(6).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let blocks = indexer + .get_block_range(BlockHeight::from(1u32), tip) + .await?; + assert_eq!( + blocks.len() as u64, + u64::from(tip), + "the served range must cover every height in [1, {tip}]" + ); + + // Two independent halves per height, collected across the whole chain so one run + // separates them: the raw-block predicate is class 1 (zebrad's own consensus + // routing, zaino uninvolved), the raw-vs-served comparison is class 2 (zaino's + // pool routing on the wire). + let vrpc = validator.json_rpc().await?; + let mut violations: Vec = Vec::new(); + for (index, served) in blocks.iter().enumerate() { + let height = index as u64 + 1; + assert_eq!(served.height, height, "served blocks must be contiguous"); + + let raw = vrpc + .call_value("getblock", json!([height.to_string(), 0])) + .await?; + let raw = zaino_testutils::hex::decode( + raw.as_str() + .context("verbosity-0 getblock returns a hex string")?, + "getblock verbosity 0", + )?; + let coinbase = FullBlock::parse_from_hex(&raw, None)? + .transactions() + .into_iter() + .next() + .context("every block carries a coinbase transaction")?; + + // A coinbase input is the single null prevout (all-zero hash, index u32::MAX). + let inputs = coinbase.transparent_inputs(); + let is_coinbase = matches!(inputs.as_slice(), [(prevout, u32::MAX, _)] if prevout.iter().all(|b| *b == 0)); + let version = coinbase.version(); + let sapling = coinbase.shielded_outputs().len(); + let orchard = coinbase.orchard_actions().len(); + let ironwood = coinbase.ironwood_actions().len(); + + // The reward lands in exactly one pool; anything else is `None` and fails. + let observed = match (version, sapling, orchard, ironwood) { + (4, 1.., 0, 0) => Some(CoinbaseEra::Sapling), + (5, 0, 1.., 0) => Some(CoinbaseEra::Orchard), + (6, 0, 0, 1..) => Some(CoinbaseEra::Ironwood), + _ => None, + }; + + let expected = match height { + h if h >= 2 => CoinbaseEra::Ironwood, + _ => CoinbaseEra::Sapling, + }; + + let served_orchard: usize = served.vtx.iter().map(|tx| tx.actions.len()).sum(); + let served_ironwood: usize = served.vtx.iter().map(|tx| tx.ironwood_actions.len()).sum(); + let wire_ok = served_orchard == orchard && served_ironwood == ironwood; + + if !is_coinbase || observed != Some(expected) || !wire_ok { + violations.push(format!( + "height {height}: want {expected:?}, raw is {observed:?} \ + (is_coinbase {is_coinbase}, v{version}, sapling {sapling}, \ + orchard {orchard}, ironwood {ironwood}); \ + zaino served orchard {served_orchard}, ironwood {served_ironwood}" + )); } - }) - .await; + } + assert!( + violations.is_empty(), + "coinbase routing mismatches ({} of {} heights):\n{}", + violations.len(), + blocks.len(), + violations.join("\n") + ); + + Ok(()) } /// The transition: the same unchanged orchard-receiver miner produces Orchard /// coinbases through NU6.2 and Ironwood coinbases from the NU6.3 activation height — /// each predicate exactly delimiting its era, so a mis-timed flip fails on both sides /// of the boundary. -/// -/// multi_thread required: the test manager spawns the validator and indexer services. +#[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] -async fn orchard_coinbase_routing_flips_to_ironwood_at_activation_zebrad() { +async fn orchard_coinbase_routing_flips_to_ironwood_at_activation_zebrad() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(), + ); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(Pool::Orchard)); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + // Two blocks past the boundary, so both eras carry more than one block. - assert_coinbase_routing( - ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS, - NU6_3_TRANSITION_BOUNDARY + 2, - |height| { - if height >= u64::from(NU6_3_TRANSITION_BOUNDARY) { - CoinbaseEra::Ironwood - } else if height >= 2 { - CoinbaseEra::Orchard - } else { - CoinbaseEra::Neither - } - }, - ) - .await; + let tip = validator + .generate_blocks(NU6_3_TRANSITION_BOUNDARY + 2) + .await?; + indexer.wait_for_block_num(tip, READY).await?; + + let blocks = indexer + .get_block_range(BlockHeight::from(1u32), tip) + .await?; + assert_eq!( + blocks.len() as u64, + u64::from(tip), + "the served range must cover every height in [1, {tip}]" + ); + + // Two independent halves per height, collected across the whole chain so one run + // separates them: the raw-block predicate is class 1 (zebrad's own consensus + // routing, zaino uninvolved), the raw-vs-served comparison is class 2 (zaino's + // pool routing on the wire). + let vrpc = validator.json_rpc().await?; + let mut violations: Vec = Vec::new(); + for (index, served) in blocks.iter().enumerate() { + let height = index as u64 + 1; + assert_eq!(served.height, height, "served blocks must be contiguous"); + + let raw = vrpc + .call_value("getblock", json!([height.to_string(), 0])) + .await?; + let raw = zaino_testutils::hex::decode( + raw.as_str() + .context("verbosity-0 getblock returns a hex string")?, + "getblock verbosity 0", + )?; + let coinbase = FullBlock::parse_from_hex(&raw, None)? + .transactions() + .into_iter() + .next() + .context("every block carries a coinbase transaction")?; + + // A coinbase input is the single null prevout (all-zero hash, index u32::MAX). + let inputs = coinbase.transparent_inputs(); + let is_coinbase = matches!(inputs.as_slice(), [(prevout, u32::MAX, _)] if prevout.iter().all(|b| *b == 0)); + let version = coinbase.version(); + let sapling = coinbase.shielded_outputs().len(); + let orchard = coinbase.orchard_actions().len(); + let ironwood = coinbase.ironwood_actions().len(); + + // The reward lands in exactly one pool; anything else is `None` and fails. + let observed = match (version, sapling, orchard, ironwood) { + (4, 1.., 0, 0) => Some(CoinbaseEra::Sapling), + (5, 0, 1.., 0) => Some(CoinbaseEra::Orchard), + (6, 0, 0, 1..) => Some(CoinbaseEra::Ironwood), + _ => None, + }; + + let expected = match height { + h if h >= u64::from(NU6_3_TRANSITION_BOUNDARY) => CoinbaseEra::Ironwood, + h if h >= 2 => CoinbaseEra::Orchard, + _ => CoinbaseEra::Sapling, + }; + + let served_orchard: usize = served.vtx.iter().map(|tx| tx.actions.len()).sum(); + let served_ironwood: usize = served.vtx.iter().map(|tx| tx.ironwood_actions.len()).sum(); + let wire_ok = served_orchard == orchard && served_ironwood == ironwood; + + if !is_coinbase || observed != Some(expected) || !wire_ok { + violations.push(format!( + "height {height}: want {expected:?}, raw is {observed:?} \ + (is_coinbase {is_coinbase}, v{version}, sapling {sapling}, \ + orchard {orchard}, ironwood {ironwood}); \ + zaino served orchard {served_orchard}, ironwood {served_ironwood}" + )); + } + } + assert!( + violations.is_empty(), + "coinbase routing mismatches ({} of {} heights):\n{}", + violations.len(), + blocks.len(), + violations.join("\n") + ); + + Ok(()) } diff --git a/live-tests/clientless/tests/fetch_service.rs b/live-tests/clientless/tests/fetch_service.rs index a9f25a6f..710c7e15 100644 --- a/live-tests/clientless/tests/fetch_service.rs +++ b/live-tests/clientless/tests/fetch_service.rs @@ -1,845 +1,594 @@ //! These tests compare the output of `FetchService` with the output of `JsonRpcConnector`. -use clientless::rpc::z_validate_address::run_z_validate_for; -use futures::StreamExt as _; -use zaino_address::ValidatedAddress; -use zaino_proto::proto::compact_formats::CompactBlock; -use zaino_proto::proto::service::{BlockId, BlockRange, GetSubtreeRootsArg}; -use zaino_serve::rpc::jsonrpc::wire::{ - block_header::GetBlockHeader, block_subsidy::GetBlockSubsidy, mining_info::GetMiningInfoWire, - peer_info::GetPeerInfo, -}; -use zaino_state::{LightWalletIndexer, NodeBackedIndexerServiceSubscriber, ZcashIndexer}; -use zaino_status::{Status, StatusType}; -use zaino_testutils::ValidatorOracle; -use zaino_testutils::{Rpc, TestManager, ValidatorExt, ValidatorKind}; -use zebra_chain::parameters::subsidy::ParameterSubsidy as _; -use zebra_rpc::methods::{GetBlock, GetBlockHash}; - -async fn launch_fetch_service( - validator: &ValidatorKind, - chain_cache: Option, -) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, chain_cache).await; - assert_eq!(fetch_service_subscriber.status(), StatusType::Ready); - dbg!(fetch_service_subscriber.data.clone()); - dbg!(fetch_service_subscriber.get_info().await.unwrap()); - dbg!( - fetch_service_subscriber - .get_blockchain_info() - .await - .unwrap() - .blocks - ); - - test_manager.close().await; -} +use std::time::Duration; -/// Fetch block 1 at the given `getblock` verbosity (0 = hex encoded data, -/// 1 = JSON object). -#[allow(deprecated)] -async fn fetch_service_get_block_at_verbosity( - validator: &ValidatorKind, - verbosity: u8, -) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - dbg!(fetch_service_subscriber - .z_get_block("1".to_string(), Some(verbosity)) - .await - .unwrap()); - - test_manager.close().await; -} - -async fn fetch_service_get_block_raw(validator: &ValidatorKind) { - fetch_service_get_block_at_verbosity::(validator, 0).await; -} +use anyhow::{Context, Result}; +use clientless::rpc::z_validate_address::run_z_validate_for; +use rstest::rstest; +use serde_json::{json, Value}; +use zaino_testutils::{assert_json_equal_ignoring, assert_rpc_parity}; +use ztest::prelude::*; -async fn fetch_service_get_block_object(validator: &ValidatorKind) { - fetch_service_get_block_at_verbosity::(validator, 1).await; -} +const READY: Duration = Duration::from_secs(60); -#[allow(deprecated)] -async fn fetch_service_get_latest_block(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - test_manager - .generate_blocks_and_wait_for_tip(1, &fetch_service_subscriber) - .await; - - let json_service = test_manager.full_node_jsonrpc_connector().await; - - let fetch_service_get_latest_block = - dbg!(fetch_service_subscriber.get_latest_block().await.unwrap()); - - let json_service_blockchain_info = json_service.get("getblockchaininfo").await; - - // The validator writes its tip hash in display order; `BlockId.hash` is - // internal order, so it is reversed on the way in. - let mut tip_hash = <[u8; 32]>::try_from( - hex::decode( - json_service_blockchain_info["bestblockhash"] - .as_str() - .expect("a best block hash"), - ) - .expect("a hex best block hash") - .as_slice(), - ) - .expect("a 32-byte best block hash"); - tip_hash.reverse(); - - let json_service_get_latest_block = dbg!(BlockId { - height: json_service_blockchain_info["blocks"] - .as_u64() - .expect("a chain height"), - hash: tip_hash.to_vec(), - }); - - assert_eq!(fetch_service_get_latest_block.height, 3); - assert_eq!( - fetch_service_get_latest_block, - json_service_get_latest_block - ); - - test_manager.close().await; -} +/// Blocks each `get` test mines to reach the two-block chain the pre-ztest +/// harness left at launch, which these assertions were written against. +/// `TestEnv::build` already mines one warm-up block per regtest validator. +const BASELINE: u32 = 1; -/// Launch a fetch-backend manager, run `fetch_query` against the Zaino -/// `FetchService` subscriber and `rpc_query` against the validator's JSON-RPC, -/// then assert the two results are equal. Collapses the -/// `assert_fetch_service_*_matches_rpc` helpers that differ only by the query. -#[allow(deprecated)] -async fn assert_subscriber_matches_rpc( - validator: &ValidatorKind, - fetch_query: impl FnOnce(NodeBackedIndexerServiceSubscriber) -> FFut, - rpc_query: impl FnOnce(ValidatorOracle) -> RFut, -) where - V: ValidatorExt, - T: std::fmt::Debug + PartialEq, - FFut: std::future::Future, - RFut: std::future::Future, -{ - let (test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - let from_fetch = fetch_query(fetch_service_subscriber).await; - let jsonrpc_client = test_manager.full_node_jsonrpc_connector().await; - let from_rpc = rpc_query(jsonrpc_client).await; - assert_eq!(from_fetch, from_rpc); -} - -#[allow(deprecated)] -async fn assert_fetch_service_difficulty_matches_rpc(validator: &ValidatorKind) { - assert_subscriber_matches_rpc::( - validator, - |sub| async move { sub.get_difficulty().await.unwrap() }, - |client| async move { - client - .get("getdifficulty") - .await - .as_f64() - .expect("a difficulty") - }, - ) - .await; -} +mod launch { + use super::*; -/// Every `getmininginfo` field the validator reports must match what Zaino -/// serves. -/// -/// Compared key by key over the validator's own response rather than as whole -/// objects. Zaino additionally emits the zcashd-only local-miner fields -/// (`genproclimit`, `localsolps`, `generate`, `pooledtx`, `errorstimestamp`) as -/// explicit JSON `null`, where zebrad omits the keys — long-standing behaviour -/// of this response type, and a client asking a zcashd-shaped interface gets a -/// zcashd-shaped answer. What must not differ is any value the validator -/// actually sent. -#[allow(deprecated)] -async fn assert_fetch_service_mininginfo_matches_rpc(validator: &ValidatorKind) { - let (test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - let served = serde_json::to_value(GetMiningInfoWire::from_domain( - fetch_service_subscriber.get_mining_info().await.unwrap(), - )) - .unwrap(); - - let reported = test_manager - .full_node_jsonrpc_connector() - .await - .get("getmininginfo") - .await; - - for (field, expected) in reported - .as_object() - .expect("getmininginfo returns an object") - { - assert_eq!( - &served[field], expected, - "`{field}` differs from the validator's own getmininginfo" + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn regtest_no_cache( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let _validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let info = indexer.indexer_info().await?; + assert!( + !info.chain_name.is_empty(), + "indexer chain_name must be set: {info:?}" ); + Ok(()) } } -#[cfg(feature = "zcashd_support")] -#[allow(deprecated)] -async fn assert_fetch_service_gettxoutsetinfo_matches_rpc( - validator: &ValidatorKind, -) { - let (test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - let fetch_service_txoutset_info = fetch_service_subscriber - .get_tx_out_set_info() - .await - .unwrap(); - - let jsonrpc_client = test_manager.full_node_jsonrpc_connector().await; - - let rpc_txoutset_info = jsonrpc_client.get("gettxoutsetinfo").await; - - // Structural parity with zcashd: height, bestblock, transactions, txouts and total_amount - // must match. `bytes_serialized` and `hash_serialized` are Zaino-defined (see the - // `gettxoutsetinfo` spec in zaino-state) and intentionally diverge from zcashd; only - // Zaino-internal invariants are asserted on those fields. - let zaino = serde_json::to_value(fetch_service_txoutset_info).unwrap(); - let zcashd = rpc_txoutset_info; - assert!( - zaino.get("height").is_some() && zcashd.get("height").is_some(), - "expected non-empty gettxoutsetinfo from both sides, got {zaino:?} / {zcashd:?}" - ); - - for field in ["height", "bestblock", "transactions", "txouts"] { - assert_eq!(zaino[field], zcashd[field], "`{field}` differs from zcashd"); - } - let amount = |value: &serde_json::Value| { - value["total_amount"] - .as_f64() - .expect("gettxoutsetinfo reports total_amount as a number") - }; - assert!( - (amount(&zaino) - amount(&zcashd)).abs() < 1e-8, - "`total_amount` differs from zcashd: zaino={} zcashd={}", - amount(&zaino), - amount(&zcashd) - ); - - // Zaino-only invariants on the redefined fields. - assert_eq!( - zaino["bytes_serialized"].as_u64().expect("a byte count"), - zaino["txouts"].as_u64().expect("a txout count") * 65, - "`bytes_serialized` must equal `txouts * 65` under Zaino's UTXO entry encoding" - ); - let hash_serialized = zaino["hash_serialized"] - .as_str() - .expect("`hash_serialized` is a string"); - assert_eq!( - hash_serialized.len(), - 64, - "`hash_serialized` must be 64 lowercase hex chars" - ); - assert!( - hash_serialized.chars().all(|c| c.is_ascii_hexdigit()), - "`hash_serialized` must be hex: got {hash_serialized}" - ); -} - -#[allow(deprecated)] -async fn assert_fetch_service_peerinfo_matches_rpc(validator: &ValidatorKind) { - assert_subscriber_matches_rpc::( - validator, - |sub| async move { - serde_json::to_value(GetPeerInfo::from_domain(sub.get_peer_info().await.unwrap())) - .unwrap() - }, - |client| async move { client.get("getpeerinfo").await }, - ) - .await; -} +mod validation { + use super::*; -#[allow(deprecated)] -async fn fetch_service_get_block_subsidy(validator: &ValidatorKind) { - let (test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - // getblocksubsidy is a pure function of (height, network schedule): the - // validator never reads the chain for an explicit height, so no mining is - // needed — heights far past the tip answer identically. Sweep through the - // first halving boundary plus a margin on both validators. - let height_limit = fetch_service_subscriber - .network() - .height_for_first_halving() - .0 - + 10; - - let jsonrpc_client = test_manager.full_node_jsonrpc_connector().await; - - for height in 0..height_limit { - let fetch_service_get_block_subsidy = fetch_service_subscriber - .get_block_subsidy(height) - .await - .unwrap(); - - let rpc_block_subsidy_response = jsonrpc_client - .call("getblocksubsidy", vec![serde_json::json!(height)]) - .await; - assert_eq!( - serde_json::to_value(GetBlockSubsidy::from_domain( - fetch_service_get_block_subsidy - )) - .unwrap(), - rpc_block_subsidy_response - ); + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn validate_address( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let _validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let irpc = indexer.json_rpc().await?; + for (addr, is_script) in [ + ("tm9iMLAuYMzJ6jtFLcA7rzUmfreGuKvr7Ma", false), + ("t26YoyZ1iPgiMEWL4zGUm74eVWfhyDMXzY2", true), + ] { + let i = irpc.call_value("validateaddress", json!([addr])).await?; + assert_eq!( + i.get("isvalid").and_then(Value::as_bool), + Some(true), + "{addr} isvalid" + ); + assert_eq!( + i.get("address").and_then(Value::as_str), + Some(addr), + "{addr} address echo" + ); + assert_eq!( + i.get("isscript").and_then(Value::as_bool), + Some(is_script), + "{addr} isscript" + ); + } + Ok(()) } -} - -#[allow(deprecated)] -async fn fetch_service_get_block(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - let block_id = BlockId { - height: 1, - hash: Vec::new(), - }; - - let fetch_service_get_block = dbg!(fetch_service_subscriber - .get_block(block_id.clone()) - .await - .unwrap()); - - assert_eq!(fetch_service_get_block.height, block_id.height); - let block_id_by_hash = BlockId { - height: 0, - hash: fetch_service_get_block.hash.clone(), - }; - let fetch_service_get_block_by_hash = fetch_service_subscriber - .get_block(block_id_by_hash.clone()) - .await - .unwrap(); - assert_eq!(fetch_service_get_block_by_hash.hash, block_id_by_hash.hash); - - test_manager.close().await; -} - -#[allow(deprecated)] -async fn fetch_service_get_block_header(validator: &ValidatorKind) { - let (test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - const BLOCK_LIMIT: u32 = 10; - - let jsonrpc_client = test_manager.full_node_jsonrpc_connector().await; - - test_manager - .generate_blocks_and_check_each( - BLOCK_LIMIT, - &fetch_service_subscriber, - &fetch_service_subscriber, - async |i| { - let block = fetch_service_subscriber - .z_get_block(i.to_string(), Some(1)) - .await - .unwrap(); - - let block_hash = match block { - GetBlock::Object(block) => block.hash(), - GetBlock::Raw(_) => panic!("Expected block object"), - }; - - let fetch_service_get_block_header = GetBlockHeader::compact_from_domain( - fetch_service_subscriber - .get_raw_block_header(block_hash.to_string()) - .await - .unwrap(), - ); - - let rpc_block_header_response = jsonrpc_client - .call( - "getblockheader", - vec![ - serde_json::json!(block_hash.to_string()), - serde_json::json!(false), - ], - ) - .await; - - let fetch_service_get_block_header_verbose = GetBlockHeader::verbose_from_domain( - fetch_service_subscriber - .get_block_header(block_hash.to_string()) - .await - .unwrap(), - ); - - let rpc_block_header_response_verbose = jsonrpc_client - .call( - "getblockheader", - vec![ - serde_json::json!(block_hash.to_string()), - serde_json::json!(true), - ], - ) - .await; - - assert_eq!( - serde_json::to_value(fetch_service_get_block_header).unwrap(), - rpc_block_header_response - ); - assert_eq!( - serde_json::to_value(fetch_service_get_block_header_verbose).unwrap(), - rpc_block_header_response_verbose - ); - }, - ) - .await; -} - -/// Launch a fetch-backend manager and mine 5 blocks, leaving the chain tip at -/// height 7 (a fresh regtest launch starts at height 2). -#[allow(deprecated)] -async fn launch_and_mine_five_blocks( - validator: &ValidatorKind, -) -> (TestManager, NodeBackedIndexerServiceSubscriber) { - let (test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - test_manager - .generate_blocks_and_wait_for_tip(5, &fetch_service_subscriber) - .await; - - (test_manager, fetch_service_subscriber) -} - -#[allow(deprecated)] -async fn fetch_service_get_best_blockhash(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - launch_and_mine_five_blocks::(validator).await; - - let inspected_block: GetBlock = fetch_service_subscriber - // Some(verbosity) : 1 for JSON Object, 2 for tx data as JSON instead of hex - .z_get_block("7".to_string(), Some(1)) - .await - .unwrap(); - - let ret = match inspected_block { - GetBlock::Object(obj) => Some(obj.hash()), - _ => None, - }; - - let fetch_service_get_best_blockhash: GetBlockHash = - dbg!(fetch_service_subscriber.get_best_blockhash().await.unwrap()); - - assert_eq!( - fetch_service_get_best_blockhash.hash(), - ret.expect("ret to be Some(GetBlockHash) not None") - ); - - test_manager.close().await; -} - -#[allow(deprecated)] -async fn fetch_service_get_block_count(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - launch_and_mine_five_blocks::(validator).await; - - let block_id = BlockId { - height: 7, - hash: Vec::new(), - }; - - let fetch_service_get_block_count = - dbg!(fetch_service_subscriber.get_block_count().await.unwrap()); - - assert_eq!(fetch_service_get_block_count.0 as u64, block_id.height); - - test_manager.close().await; -} - -#[allow(deprecated)] -async fn fetch_service_validate_address(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - // scriptpubkey: "76a914000000000000000000000000000000000000000088ac" - let expected_validation = ValidatedAddress::Transparent { - address: "tm9iMLAuYMzJ6jtFLcA7rzUmfreGuKvr7Ma".to_string(), - is_script: false, - }; - let fetch_service_validate_address = fetch_service_subscriber - .validate_address("tm9iMLAuYMzJ6jtFLcA7rzUmfreGuKvr7Ma".to_string()) - .await - .unwrap(); - - assert_eq!(fetch_service_validate_address, expected_validation); - - // scriptpubkey: "a914000000000000000000000000000000000000000087" - let expected_validation_script = ValidatedAddress::Transparent { - address: "t26YoyZ1iPgiMEWL4zGUm74eVWfhyDMXzY2".to_string(), - is_script: true, - }; - - let fetch_service_validate_address_script = fetch_service_subscriber - .validate_address("t26YoyZ1iPgiMEWL4zGUm74eVWfhyDMXzY2".to_string()) - .await - .unwrap(); - - assert_eq!( - fetch_service_validate_address_script, - expected_validation_script - ); - - test_manager.close().await; -} - -/// Launch a fetch-backend manager and run the shared `z_validate_address` -/// suite against its subscriber. -async fn z_validate(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - run_z_validate_for(&fetch_service_subscriber).await; - - test_manager.close().await; -} - -#[allow(deprecated)] -async fn fetch_service_get_block_nullifiers(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - let block_id = BlockId { - height: 1, - hash: Vec::new(), - }; - - let fetch_service_get_block_nullifiers = dbg!(fetch_service_subscriber - .get_block_nullifiers(block_id.clone()) - .await - .unwrap()); - - assert_eq!(fetch_service_get_block_nullifiers.height, block_id.height); - - test_manager.close().await; -} - -#[allow(deprecated)] -async fn fetch_service_get_block_range(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - test_manager - .generate_blocks_and_wait_for_tip(10, &fetch_service_subscriber) - .await; - - let fetch_blocks = - zaino_testutils::collect_block_range(&fetch_service_subscriber, 1, 10, vec![]).await; - - dbg!(fetch_blocks); - - test_manager.close().await; -} - -// TODO(#1088): replace deprecated nullifier-range client usage. -#[allow(deprecated)] -async fn fetch_service_get_block_range_nullifiers(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - test_manager - .generate_blocks_and_wait_for_tip(10, &fetch_service_subscriber) - .await; - - let block_range = BlockRange { - start: Some(BlockId { - height: 1, - hash: Vec::new(), - }), - end: Some(BlockId { - height: 10, - hash: Vec::new(), - }), - pool_types: zaino_testutils::all_pools_i32(), - }; - - let fetch_service_stream = fetch_service_subscriber - .get_block_range_nullifiers(block_range.clone()) - .await - .unwrap(); - let fetch_service_compact_blocks: Vec<_> = fetch_service_stream.collect().await; - - let fetch_nullifiers: Vec = fetch_service_compact_blocks - .into_iter() - .filter_map(|result| result.ok()) - .collect(); - - dbg!(fetch_nullifiers); - - test_manager.close().await; -} - -#[allow(deprecated)] -async fn fetch_service_get_tree_state(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - let chain_height = dbg!(fetch_service_subscriber.chain_height().await.unwrap()).0; - - let block_id = BlockId { - height: chain_height as u64, - hash: Vec::new(), - }; - dbg!(fetch_service_subscriber - .get_tree_state(block_id) - .await - .unwrap()); - - test_manager.close().await; -} - -#[allow(deprecated)] -async fn fetch_service_get_latest_tree_state(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - dbg!(fetch_service_subscriber - .get_latest_tree_state() - .await - .unwrap()); - - test_manager.close().await; -} - -#[allow(deprecated)] -async fn fetch_service_get_subtree_roots(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - let subtree_roots_arg = GetSubtreeRootsArg { - start_index: 0, - shielded_protocol: 1, - max_entries: 0, - }; - - let fetch_service_stream = fetch_service_subscriber - .get_subtree_roots(subtree_roots_arg) - .await - .unwrap(); - let fetch_service_roots: Vec<_> = fetch_service_stream.collect().await; - - let fetch_roots: Vec<_> = fetch_service_roots - .into_iter() - .filter_map(|result| result.ok()) - .collect(); - - dbg!(fetch_roots); - - test_manager.close().await; -} - -#[allow(deprecated)] -async fn fetch_service_get_lightd_info(validator: &ValidatorKind) { - let (mut test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - dbg!(fetch_service_subscriber.get_lightd_info().await.unwrap()); - - test_manager.close().await; -} - -#[allow(deprecated)] -async fn assert_fetch_service_getnetworksols_matches_rpc( - validator: &ValidatorKind, -) { - assert_subscriber_matches_rpc::( - validator, - |sub| async move { sub.get_network_sol_ps(None, None).await.unwrap() }, - |client| async move { - client - .get("getnetworksolps") - .await - .as_u64() - .expect("a solution rate") - }, - ) - .await; + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + pub(crate) async fn z_validate_address( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let _validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let irpc = indexer.json_rpc().await?; + run_z_validate_for(&irpc).await + } } -#[cfg(feature = "zcashd_support")] -#[allow(deprecated)] -async fn fetch_service_get_block_deltas(validator: &ValidatorKind) { - use zaino_serve::rpc::jsonrpc::wire::block_deltas::BlockDeltas; - - let (test_manager, fetch_service_subscriber) = - zaino_testutils::launch_with_fetch_subscriber::(validator, None).await; - - let current_block = fetch_service_subscriber.get_latest_block().await.unwrap(); - - let block_hash_bytes: [u8; 32] = current_block.hash.as_slice().try_into().unwrap(); - - let block_hash = zebra_chain::block::Hash::from(block_hash_bytes); - - // Note: we need an 'expected' block hash in order to query its deltas. - // Having a predictable or test vector chain is the way to go here. - let fetch_service_block_deltas = fetch_service_subscriber - .get_block_deltas(block_hash.to_string()) - .await - .unwrap(); - - let jsonrpc_client = test_manager.full_node_jsonrpc_connector().await; - - let rpc_block_deltas = jsonrpc_client - .get_block_deltas(block_hash.to_string()) - .await - .unwrap(); - - assert_eq!( - serde_json::to_value(BlockDeltas::from_domain(fetch_service_block_deltas).unwrap()) - .unwrap(), - serde_json::to_value(rpc_block_deltas).unwrap() - ); -} +mod get { + use super::*; -#[cfg(feature = "zcashd_support")] -mod zcashd { + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn block_raw( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let _block = indexer + .json_rpc() + .await? + .call_value("getblock", json!(["1", 0])) + .await?; + Ok(()) + } - use super::*; - use zcash_local_net::validator::zcashd::Zcashd; + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn block_object( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let _block = indexer + .json_rpc() + .await? + .call_value("getblock", json!(["1", 1])) + .await?; + Ok(()) + } - mod launch { + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn latest_block( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let indexer_tip = indexer.latest_block_height().await?; + let validator_tip = validator.chain_height().await?; + assert_eq!( + indexer_tip, validator_tip, + "indexer tip ({indexer_tip}) must equal validator tip ({validator_tip})" + ); - use super::*; + let indexer_best_hash = indexer + .json_rpc() + .await? + .call_value("getbestblockhash", json!([])) + .await?; + let validator_best_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", json!([])) + .await?; + assert_eq!( + indexer_best_hash, validator_best_hash, + "indexer best block hash must equal validator best block hash" + ); + Ok(()) + } - #[tokio::test(flavor = "multi_thread")] - pub(crate) async fn regtest_no_cache() { - launch_fetch_service::(&ValidatorKind::Zcashd, None).await; - } + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn block(#[case] validator: Validator) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let by_height = indexer.get_block(BlockHeight::from(1u32)).await?; + assert_eq!(by_height.height, 1, "get_block(1).height"); + assert_eq!(by_height.hash.len(), 32, "block hash must be 32 bytes"); + let hash_bytes: [u8; 32] = by_height + .hash + .clone() + .try_into() + .ok() + .context("block hash must be 32 bytes")?; + let by_hash = indexer.get_block_by_hash(BlockHash(hash_bytes)).await?; + assert_eq!( + by_height.height, by_hash.height, + "by-hash height round-trip" + ); + assert_eq!(by_height.hash, by_hash.hash, "by-hash hash round-trip"); + Ok(()) + } - #[tokio::test(flavor = "multi_thread")] - #[ignore = "We no longer use chain caches. See zcashd::launch::regtest_no_cache."] - pub(crate) async fn regtest_with_cache() { - launch_fetch_service::( - &ValidatorKind::Zcashd, - zaino_testutils::ZCASHD_CHAIN_CACHE_DIR.clone(), - ) - .await; + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn block_header( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + for i in 0u32..10 { + let tip = validator.generate_blocks(BASELINE + 1).await?; + indexer.wait_for_block_num(tip, READY).await?; + let blk = vrpc + .call_value("getblock", json!([i.to_string(), 1])) + .await?; + let hash = blk + .get("hash") + .and_then(Value::as_str) + .with_context(|| format!("getblock({i},1).hash missing"))? + .to_string(); + for verbose in [false, true] { + let params = format!(r#"["{hash}", {verbose}]"#); + assert_rpc_parity("getblockheader", ¶ms, &vrpc, &irpc, &[]).await?; + } } + Ok(()) } - mod validation { + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn difficulty( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + assert_rpc_parity("getdifficulty", "", &vrpc, &irpc, &[]).await?; + Ok(()) + } - use super::*; + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn mining_info( + #[case] validator: Validator, + ) -> Result<()> { + let ignore: &[&str] = &[ + "difficulty", + "errors", + "errorstimestamp", + "genproclimit", + "localsolps", + "pooledtx", + "generate", + ]; + + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let v = validator + .json_rpc() + .await? + .call_value("getmininginfo", json!([])) + .await?; + let i = indexer + .json_rpc() + .await? + .call_value("getmininginfo", json!([])) + .await?; + assert_json_equal_ignoring("getmininginfo", v, i, ignore); + Ok(()) + } - #[tokio::test(flavor = "multi_thread")] - pub(crate) async fn validate_address() { - fetch_service_validate_address::(&ValidatorKind::Zcashd).await; - } + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn peer_info( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + assert_rpc_parity("getpeerinfo", "", &vrpc, &irpc, &[]).await?; + Ok(()) + } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - pub(crate) async fn z_validate_address() { - z_validate::(&ValidatorKind::Zcashd).await; + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn block_subsidy( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + // getblocksubsidy is a pure function of (height, network schedule): the + // validator never reads the chain for an explicit height, so no mining is + // needed — heights far past the tip answer identically. Sweep through the + // first halving boundary plus a margin on both validators. + let block_limit = match validator.chain_config().await?.first_halving_height { + Some(first_halving) => u32::from(first_halving) + 10, + None => 10, + }; + let tip = validator.generate_blocks(BASELINE + block_limit).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + for height in 0u32..block_limit { + let params = format!("[{height}]"); + assert_rpc_parity("getblocksubsidy", ¶ms, &vrpc, &irpc, &[]).await?; } + Ok(()) } - mod get { - use super::*; - - zaino_testutils::validator_tests!( - Zcashd, - ValidatorKind::Zcashd, - block_raw => fetch_service_get_block_raw, - block_object => fetch_service_get_block_object, - latest_block => fetch_service_get_latest_block, - block => fetch_service_get_block, - block_header => fetch_service_get_block_header, - difficulty => assert_fetch_service_difficulty_matches_rpc, - block_deltas => fetch_service_get_block_deltas, - mining_info => assert_fetch_service_mininginfo_matches_rpc, - peer_info => assert_fetch_service_peerinfo_matches_rpc, - block_subsidy => fetch_service_get_block_subsidy, - best_blockhash => fetch_service_get_best_blockhash, - block_count => fetch_service_get_block_count, - block_nullifiers => fetch_service_get_block_nullifiers, - block_range => fetch_service_get_block_range, - block_range_nullifiers => fetch_service_get_block_range_nullifiers, - tree_state => fetch_service_get_tree_state, - latest_tree_state => fetch_service_get_latest_tree_state, - subtree_roots => fetch_service_get_subtree_roots, - lightd_info => fetch_service_get_lightd_info, - get_network_sol_ps => assert_fetch_service_getnetworksols_matches_rpc, - get_tx_out_set_info => assert_fetch_service_gettxoutsetinfo_matches_rpc, + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn best_blockhash( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 5).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let tip = u32::from(validator.chain_height().await?); + let irpc = indexer.json_rpc().await?; + let block = irpc + .call_value("getblock", json!([tip.to_string(), 1])) + .await?; + let block_hash = block + .get("hash") + .and_then(Value::as_str) + .context("getblock.hash missing")?; + let best_hash = irpc + .call_value("getbestblockhash", json!([])) + .await? + .as_str() + .context("getbestblockhash returned non-string")? + .to_string(); + assert_eq!( + block_hash, best_hash, + "getblock(tip).hash must equal getbestblockhash" ); + Ok(()) } -} - -mod zebrad { - - use super::*; - use zcash_local_net::validator::zebrad::Zebrad; - mod launch { + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn block_count( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 5).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + let count = assert_rpc_parity("getblockcount", "", &vrpc, &irpc, &[]).await?; + assert_eq!( + count.as_u64(), + Some(u64::from(u32::from(tip))), + "getblockcount must equal the validator's tip" + ); + Ok(()) + } - use super::*; + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn block_nullifiers( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 3).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let cb = indexer + .get_block_nullifiers(BlockHeight::from(1u32)) + .await?; + assert_eq!( + cb.height, 1, + "GetBlockNullifiers must return the requested height" + ); + Ok(()) + } - #[tokio::test(flavor = "multi_thread")] - pub(crate) async fn regtest_no_cache() { - launch_fetch_service::(&ValidatorKind::Zebrad, None).await; - } + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn block_range( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 10).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let _blocks = indexer + .get_block_range(BlockHeight::from(1u32), BlockHeight::from(10u32)) + .await?; + Ok(()) + } - #[tokio::test(flavor = "multi_thread")] - #[ignore = "We no longer use chain caches. See zebrad::launch::regtest_no_cache."] - pub(crate) async fn regtest_with_cache() { - launch_fetch_service::( - &ValidatorKind::Zebrad, - zaino_testutils::ZEBRAD_CHAIN_CACHE_DIR.clone(), - ) - .await; - } + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn block_range_nullifiers( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 10).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let _blocks = indexer + .get_block_range_nullifiers(BlockHeight::from(1u32), BlockHeight::from(10u32)) + .await?; + Ok(()) } - mod validation { + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn tree_state( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let chain_tip = validator.chain_height().await?; + let _ts = indexer.get_tree_state(chain_tip).await?; + Ok(()) + } - use super::*; + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn latest_tree_state( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let _ts = indexer.get_latest_tree_state().await?; + Ok(()) + } - #[tokio::test(flavor = "multi_thread")] - pub(crate) async fn validate_address() { - fetch_service_validate_address::(&ValidatorKind::Zebrad).await; - } + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn subtree_roots( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE + 1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let _roots = indexer + .get_subtree_roots(0, ShieldedProtocol::Sapling, 0) + .await?; + Ok(()) + } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - pub(crate) async fn z_validate_address() { - z_validate::(&ValidatorKind::Zebrad).await; - } + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn lightd_info( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let _info = indexer.indexer_info().await?; + Ok(()) } - mod get { - use super::*; - - zaino_testutils::validator_tests!( - Zebrad, - ValidatorKind::Zebrad, - block_raw => fetch_service_get_block_raw, - block_object => fetch_service_get_block_object, - latest_block => fetch_service_get_latest_block, - block => fetch_service_get_block, - block_header => fetch_service_get_block_header, - difficulty => assert_fetch_service_difficulty_matches_rpc, - mining_info => assert_fetch_service_mininginfo_matches_rpc, - peer_info => assert_fetch_service_peerinfo_matches_rpc, - block_subsidy => fetch_service_get_block_subsidy, - best_blockhash => fetch_service_get_best_blockhash, - block_count => fetch_service_get_block_count, - block_nullifiers => fetch_service_get_block_nullifiers, - block_range => fetch_service_get_block_range, - block_range_nullifiers => fetch_service_get_block_range_nullifiers, - tree_state => fetch_service_get_tree_state, - latest_tree_state => fetch_service_get_latest_tree_state, - subtree_roots => fetch_service_get_subtree_roots, - lightd_info => fetch_service_get_lightd_info, - get_network_sol_ps => assert_fetch_service_getnetworksols_matches_rpc, - ); + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn get_network_sol_ps( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest()); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(BASELINE).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + assert_rpc_parity("getnetworksolps", "", &vrpc, &irpc, &[]).await?; + Ok(()) } } diff --git a/live-tests/clientless/tests/finalisation_seam.rs b/live-tests/clientless/tests/finalisation_seam.rs new file mode 100644 index 00000000..b8546699 --- /dev/null +++ b/live-tests/clientless/tests/finalisation_seam.rs @@ -0,0 +1,289 @@ +//! Reads that cross zaino's finalised / non-finalised boundary. +//! +//! Heights at or below `tip - MAX_NONFINALISED_DEPTH` are served from the finalised +//! index, everything above from an in-memory cache. A range spanning the boundary is +//! stitched from both, so the seam is a real join in the code — the only place a served +//! chain can acquire a gap, a duplicate, or two mismatched halves. +//! +//! The operational depth is 1001 blocks, which no regtest fixture reaches, so every +//! indexer here is built with `fast-test-seam` (100). That is a separate image with its +//! own tag and is not an operational build. +//! +//! `finalised_index_advances_past_the_seam` is load-bearing: without the feature nothing +//! finalises and every assertion below holds without crossing anything. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde_json::json; +use zaino_testutils::wait_for_finalised; +use ztest::prelude::*; + +const READY: Duration = Duration::from_secs(180); +const FINALISE_TIMEOUT: Duration = Duration::from_secs(300); +/// Leaves a 60-block finalised band below the seam and the 100-block window above it. +const CHAIN_LEN: u32 = 160; +/// `FAST_TEST_MAX_NONFINALISED_DEPTH` under `fast-test-seam`. Mirrored rather than +/// imported: this workspace links no production crate. +const FAST_SEAM: u32 = 100; + +#[ztest::qos::integration] +#[tokio::test(flavor = "multi_thread")] +async fn finalised_index_advances_past_the_seam() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest()); + let indexer = env.add_indexer( + dev!( + Indexer::Zainod, + "../../Dockerfile", + features = [ + "no_tls_use_unencrypted_traffic", + "allow_unencrypted_public_json_rpc_bind", + "prometheus", + "fast-test-seam", + ] + ) + .regtest(), + ); + env.build().await?; + + let tip = validator.generate_blocks(CHAIN_LEN).await?; + indexer.wait_for_block_num(tip, READY).await?; + let tip = u32::from(tip); + let floor = tip.saturating_sub(FAST_SEAM); + + assert!( + floor > 0, + "fixture too short to have a finalised band: tip {tip}, seam depth {FAST_SEAM}" + ); + + let frontier = wait_for_finalised(&indexer, floor, FINALISE_TIMEOUT).await?; + + // Finalising the tip would claim reorg-stability for blocks consensus can still roll back. + assert!( + frontier < tip, + "the finalised index must trail the tip by the reorg buffer, but frontier \ + {frontier} reached tip {tip}" + ); + Ok(()) +} + +/// The stitch must produce one chain: no gap, no duplicate, every `prev_hash` pointing at +/// its predecessor. Height contiguity alone would not catch a stitch that served the +/// right heights off the wrong chain. +#[ztest::qos::integration] +#[tokio::test(flavor = "multi_thread")] +async fn range_across_the_seam_is_one_unbroken_chain() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest()); + let indexer = env.add_indexer( + dev!( + Indexer::Zainod, + "../../Dockerfile", + features = [ + "no_tls_use_unencrypted_traffic", + "allow_unencrypted_public_json_rpc_bind", + "prometheus", + "fast-test-seam", + ] + ) + .regtest(), + ); + env.build().await?; + + let tip = validator.generate_blocks(CHAIN_LEN).await?; + indexer.wait_for_block_num(tip, READY).await?; + let tip = u32::from(tip); + let floor = tip.saturating_sub(FAST_SEAM); + wait_for_finalised(&indexer, floor, FINALISE_TIMEOUT).await?; + + let blocks = indexer + .get_block_range(BlockHeight::from(1u32), BlockHeight::from(tip)) + .await?; + assert_eq!( + blocks.len(), + tip as usize, + "a range spanning the seam must serve every height in [1, {tip}]" + ); + + for (offset, block) in blocks.iter().enumerate() { + assert_eq!( + block.height, + (offset + 1) as u64, + "served heights must ascend without gap or repeat across the seam (floor {floor})" + ); + } + for pair in blocks.windows(2) { + assert_eq!( + pair[1].prev_hash, pair[0].hash, + "block {} does not link to block {} — the seam stitch joined two different \ + chains (floor {floor})", + pair[1].height, pair[0].height + ); + } + + Ok(()) +} + +/// A height must not depend on which side of the seam the request fell on: each +/// straddling height is fetched inside a spanning range, inside a range confined to its +/// own side, and as a single read, and all three must agree exactly. +#[ztest::qos::integration] +#[tokio::test(flavor = "multi_thread")] +async fn seam_blocks_are_identical_whichever_side_serves_them() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest()); + let indexer = env.add_indexer( + dev!( + Indexer::Zainod, + "../../Dockerfile", + features = [ + "no_tls_use_unencrypted_traffic", + "allow_unencrypted_public_json_rpc_bind", + "prometheus", + "fast-test-seam", + ] + ) + .regtest(), + ); + env.build().await?; + + let tip = validator.generate_blocks(CHAIN_LEN).await?; + indexer.wait_for_block_num(tip, READY).await?; + let floor = u32::from(tip).saturating_sub(FAST_SEAM); + wait_for_finalised(&indexer, floor, FINALISE_TIMEOUT).await?; + + let straddling = [floor - 1, floor, floor + 1, floor + 2]; + let spanning = indexer + .get_block_range(BlockHeight::from(floor - 1), BlockHeight::from(floor + 2)) + .await?; + assert_eq!( + spanning.len(), + straddling.len(), + "the spanning request must serve all four straddling heights" + ); + + for (height, from_span) in straddling.iter().zip(spanning.iter()) { + assert_eq!(from_span.height, u64::from(*height), "spanning range order"); + + let confined = indexer + .get_block_range(BlockHeight::from(*height), BlockHeight::from(*height)) + .await?; + assert_eq!(confined.len(), 1, "single-height range at {height}"); + assert_eq!( + &confined[0], from_span, + "height {height} differs between a seam-spanning request and one confined \ + to its own side (floor {floor})" + ); + + let single = indexer.get_block(BlockHeight::from(*height)).await?; + assert_eq!( + &single, from_span, + "height {height} differs between GetBlock and a seam-spanning GetBlockRange \ + (floor {floor})" + ); + } + Ok(()) +} + +/// The descending branch of the range split is separate code from the ascending one. +#[ztest::qos::integration] +#[tokio::test(flavor = "multi_thread")] +async fn descending_range_across_the_seam_reverses_the_ascending_one() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest()); + let indexer = env.add_indexer( + dev!( + Indexer::Zainod, + "../../Dockerfile", + features = [ + "no_tls_use_unencrypted_traffic", + "allow_unencrypted_public_json_rpc_bind", + "prometheus", + "fast-test-seam", + ] + ) + .regtest(), + ); + env.build().await?; + + let tip = validator.generate_blocks(CHAIN_LEN).await?; + indexer.wait_for_block_num(tip, READY).await?; + let floor = u32::from(tip).saturating_sub(FAST_SEAM); + wait_for_finalised(&indexer, floor, FINALISE_TIMEOUT).await?; + + let (low, high) = (floor - 5, floor + 5); + let ascending = indexer + .get_block_range(BlockHeight::from(low), BlockHeight::from(high)) + .await?; + let descending = indexer + .get_block_range(BlockHeight::from(high), BlockHeight::from(low)) + .await?; + + assert_eq!( + descending.len(), + ascending.len(), + "descending and ascending requests over [{low}, {high}] must serve the same count" + ); + let reversed: Vec<_> = descending.iter().rev().cloned().collect(); + assert_eq!( + reversed, ascending, + "a descending range across the seam must be the ascending one reversed (floor {floor})" + ); + Ok(()) +} + +/// Keeps the validator as the oracle for the finalised half, which is otherwise only ever +/// compared against zaino itself. +#[ztest::qos::integration] +#[tokio::test(flavor = "multi_thread")] +async fn served_blocks_match_the_validator_on_both_sides_of_the_seam() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest()); + let indexer = env.add_indexer( + dev!( + Indexer::Zainod, + "../../Dockerfile", + features = [ + "no_tls_use_unencrypted_traffic", + "allow_unencrypted_public_json_rpc_bind", + "prometheus", + "fast-test-seam", + ] + ) + .regtest(), + ); + env.build().await?; + + let tip = validator.generate_blocks(CHAIN_LEN).await?; + indexer.wait_for_block_num(tip, READY).await?; + let floor = u32::from(tip).saturating_sub(FAST_SEAM); + wait_for_finalised(&indexer, floor, FINALISE_TIMEOUT).await?; + + let vrpc = validator.json_rpc().await?; + let blocks = indexer + .get_block_range_with_pools( + BlockHeight::from(floor - 3), + BlockHeight::from(floor + 3), + // zaino's `PoolType` wire enum, every pool — matches `PoolTypeFilter::default`. + vec![1, 2, 3, 4], + ) + .await?; + + for block in &blocks { + let hash = vrpc + .call_value("getblockhash", json!([block.height])) + .await?; + // Wire carries internal byte order; JSON-RPC uses the reverse. + let mut served = block.hash.clone(); + served.reverse(); + assert_eq!( + zaino_testutils::hex::encode(&served), + hash.as_str().context("getblockhash returns a hex string")?, + "served block at height {} is not the validator's block at that height \ + (seam floor {floor})", + block.height + ); + } + Ok(()) +} diff --git a/live-tests/clientless/tests/json_server.rs b/live-tests/clientless/tests/json_server.rs deleted file mode 100644 index 696494e7..00000000 --- a/live-tests/clientless/tests/json_server.rs +++ /dev/null @@ -1,468 +0,0 @@ -//! Tests that compare the output of both `zcashd` and `zainod` through `FetchService`. -//! -//! Entirely gated on `zcashd_support`: every test here launches the -//! zcashd-backed dual fetch services. See -//! docs/adr/0001-zcashd-support-feature-gate.md. -#![cfg(feature = "zcashd_support")] - -use zaino_state::{NodeBackedIndexerServiceSubscriber, ZcashIndexer}; -use zaino_testutils::{Rpc, TestManager}; -use zcash_local_net::validator::zcashd::Zcashd; - -/// Assert that `query` returns the same value from the zcashd-backed and -/// zaino-backed subscribers. `query` takes the subscriber by value (subscribers -/// are `Clone`) so its future owns it — sidestepping the borrow-across-await -/// problem. The pure-compare building block of the json_server tests. -#[allow(deprecated)] -async fn assert_subscribers_agree( - zcashd_subscriber: &NodeBackedIndexerServiceSubscriber, - zaino_subscriber: &NodeBackedIndexerServiceSubscriber, - query: Q, -) where - Q: Fn(NodeBackedIndexerServiceSubscriber) -> Fut, - Fut: std::future::Future, - T: std::fmt::Debug + PartialEq, -{ - let from_zcashd = query(zcashd_subscriber.clone()).await; - let from_zaino = query(zaino_subscriber.clone()).await; - assert_eq!(from_zcashd, from_zaino); -} - -/// Mine `blocks` blocks one at a time, asserting `query` agrees across both -/// subscribers before each. The looped form of [`assert_subscribers_agree`] for -/// tests that check an invariant holds across the chain. -#[allow(deprecated)] -async fn compare_over_blocks( - test_manager: &TestManager, - zcashd_subscriber: &NodeBackedIndexerServiceSubscriber, - zaino_subscriber: &NodeBackedIndexerServiceSubscriber, - blocks: u32, - query: Q, -) where - Q: Fn(NodeBackedIndexerServiceSubscriber) -> Fut, - Fut: std::future::Future, - T: std::fmt::Debug + PartialEq, -{ - for _ in 0..blocks { - assert_subscribers_agree(zcashd_subscriber, zaino_subscriber, &query).await; - test_manager - .generate_blocks_and_wait_for_tips(1, zaino_subscriber, zcashd_subscriber) - .await; - } -} - -#[allow(deprecated)] -async fn launch_json_server_check_info() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - let zcashd_info = dbg!(services.zcashd_subscriber.get_info().await.unwrap()); - let zcashd_blockchain_info = dbg!(services - .zcashd_subscriber - .get_blockchain_info() - .await - .unwrap()); - let zaino_info = dbg!(services.zaino_subscriber.get_info().await.unwrap()); - let zaino_blockchain_info = dbg!(services - .zaino_subscriber - .get_blockchain_info() - .await - .unwrap()); - - // Clean timestamp from get_info - let cleaned_zcashd_info = zaino_testutils::get_info_with_zeroed_timestamp(zcashd_info); - - let cleaned_zaino_info = zaino_testutils::get_info_with_zeroed_timestamp(zaino_info); - - assert_eq!(cleaned_zcashd_info, cleaned_zaino_info); - - assert_eq!(zcashd_blockchain_info.chain, zaino_blockchain_info.chain); - assert_eq!(zcashd_blockchain_info.blocks, zaino_blockchain_info.blocks); - assert_eq!( - zcashd_blockchain_info.best_block_hash, - zaino_blockchain_info.best_block_hash - ); - assert_eq!( - zcashd_blockchain_info.estimated_height, - zaino_blockchain_info.estimated_height - ); - assert_eq!( - zcashd_blockchain_info.value_pools, - zaino_blockchain_info.value_pools - ); - assert_eq!( - zcashd_blockchain_info.upgrades, - zaino_blockchain_info.upgrades - ); - assert_eq!( - zcashd_blockchain_info.consensus, - zaino_blockchain_info.consensus - ); - - services.test_manager.close().await; -} - -async fn get_best_blockhash_inner() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - assert_subscribers_agree( - &services.zcashd_subscriber, - &services.zaino_subscriber, - |s| async move { dbg!(s.get_best_blockhash().await.unwrap()) }, - ) - .await; - - services.test_manager.close().await; -} - -async fn get_block_count_inner() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - assert_subscribers_agree( - &services.zcashd_subscriber, - &services.zaino_subscriber, - |s| async move { dbg!(s.get_block_count().await.unwrap()) }, - ) - .await; - - services.test_manager.close().await; -} - -async fn validate_address_inner() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - // Using a testnet transparent address - let address_string = "tmHMBeeYRuc2eVicLNfP15YLxbQsooCA6jb"; - - let address_with_script = "t3TAfQ9eYmXWGe3oPae1XKhdTxm8JvsnFRL"; - - let zcashd_valid = services - .zcashd_subscriber - .validate_address(address_string.to_string()) - .await - .unwrap(); - - let zaino_valid = services - .zaino_subscriber - .validate_address(address_string.to_string()) - .await - .unwrap(); - - assert_eq!(zcashd_valid, zaino_valid, "Address should be valid"); - - let zcashd_valid_script = services - .zcashd_subscriber - .validate_address(address_with_script.to_string()) - .await - .unwrap(); - - let zaino_valid_script = services - .zaino_subscriber - .validate_address(address_with_script.to_string()) - .await - .unwrap(); - - assert_eq!( - zcashd_valid_script, zaino_valid_script, - "Address should be valid" - ); - - services.test_manager.close().await; -} - -async fn z_get_block_inner() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - let zcashd_block_raw = dbg!(services - .zcashd_subscriber - .z_get_block("1".to_string(), Some(0)) - .await - .unwrap()); - - let zaino_block_raw = dbg!(services - .zaino_subscriber - .z_get_block("1".to_string(), Some(0)) - .await - .unwrap()); - - assert_eq!(zcashd_block_raw, zaino_block_raw); - - let zcashd_block = dbg!(services - .zcashd_subscriber - .z_get_block("1".to_string(), Some(1)) - .await - .unwrap()); - - let zaino_block = dbg!(services - .zaino_subscriber - .z_get_block("1".to_string(), Some(1)) - .await - .unwrap()); - - assert_eq!(zcashd_block, zaino_block); - - let hash = match zcashd_block { - zebra_rpc::methods::GetBlock::Raw(_) => panic!("expected object"), - zebra_rpc::methods::GetBlock::Object(obj) => obj.hash().to_string(), - }; - let zaino_get_block_by_hash = services - .zaino_subscriber - .z_get_block(hash.clone(), Some(1)) - .await - .unwrap(); - assert_eq!(zaino_get_block_by_hash, zaino_block); - - services.test_manager.close().await; -} - -async fn get_tx_out_set_info_inner() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - services.generate_blocks_and_wait_for_tips(1).await; - - let zcashd_txoutset_info = services - .zcashd_subscriber - .get_tx_out_set_info() - .await - .unwrap(); - let zaino_txoutset_info = services - .zaino_subscriber - .get_tx_out_set_info() - .await - .unwrap(); - - // Structural parity with zcashd: height, bestblock, transactions, txouts and total_amount - // must match. `bytes_serialized` and `hash_serialized` are Zaino-defined and intentionally - // diverge from zcashd; only Zaino-internal invariants are asserted on those fields. - // Both sides are Zaino subscribers here, so both are rendered through the - // served wire shape and compared as JSON. - let render = |info| { - serde_json::to_value( - zaino_serve::rpc::jsonrpc::wire::misc::TxOutSetInfoWire::from_domain(info), - ) - .unwrap() - }; - let (zaino, zcashd) = (render(zaino_txoutset_info), render(zcashd_txoutset_info)); - assert!( - zaino.get("height").is_some() && zcashd.get("height").is_some(), - "expected non-empty gettxoutsetinfo from both sides, got {zaino:?} / {zcashd:?}" - ); - - for field in ["height", "bestblock", "transactions", "txouts"] { - assert_eq!(zaino[field], zcashd[field], "`{field}` differs from zcashd"); - } - let amount = |value: &serde_json::Value| { - value["total_amount"] - .as_f64() - .expect("gettxoutsetinfo reports total_amount as a number") - }; - assert!( - (amount(&zaino) - amount(&zcashd)).abs() < 1e-8, - "`total_amount` differs from zcashd: zaino={} zcashd={}", - amount(&zaino), - amount(&zcashd) - ); - - assert_eq!( - zaino["bytes_serialized"].as_u64().expect("a byte count"), - zaino["txouts"].as_u64().expect("a txout count") * 65, - "`bytes_serialized` must equal txouts * 65 under Zaino's UTXO entry encoding" - ); - let hash_serialized = zaino["hash_serialized"] - .as_str() - .expect("`hash_serialized` is a string"); - assert_eq!( - hash_serialized.len(), - 64, - "`hash_serialized` must be 64 lowercase hex chars" - ); - assert!( - hash_serialized.chars().all(|c| c.is_ascii_hexdigit()), - "`hash_serialized` must be hex: got {hash_serialized}" - ); - - services.test_manager.close().await; -} - -// TODO: This module should not be called `zcashd` -mod zcashd { - use super::*; - - pub(crate) mod zcash_indexer { - use zaino_state::LightWalletIndexer; - - use super::*; - - #[tokio::test(flavor = "multi_thread")] - async fn check_info_no_cookie() { - launch_json_server_check_info().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn check_info_with_cookie() { - launch_json_server_check_info().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_best_blockhash() { - get_best_blockhash_inner().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_block_count() { - get_block_count_inner().await; - } - - /// Checks that the difficulty is the same between zcashd and zaino. - /// - /// This tests generates blocks and checks that the difficulty is the same between zcashd and zaino - /// after each block is generated. - #[tokio::test(flavor = "multi_thread")] - async fn get_difficulty() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - compare_over_blocks( - &services.test_manager, - &services.zcashd_subscriber, - &services.zaino_subscriber, - 10, - |s| async move { s.get_difficulty().await.unwrap() }, - ) - .await; - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_block_deltas() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - const BLOCK_LIMIT: i32 = 10; - - for _ in 0..BLOCK_LIMIT { - let current_block = services.zcashd_subscriber.get_latest_block().await.unwrap(); - - let block_hash_bytes: [u8; 32] = current_block.hash.as_slice().try_into().unwrap(); - - let block_hash = zebra_chain::block::Hash::from(block_hash_bytes); - - let zcashd_deltas = services - .zcashd_subscriber - .get_block_deltas(block_hash.to_string()) - .await - .unwrap(); - let zaino_deltas = services - .zaino_subscriber - .get_block_deltas(block_hash.to_string()) - .await - .unwrap(); - - assert_eq!(zcashd_deltas, zaino_deltas); - - services.generate_blocks_and_wait_for_tips(1).await; - } - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_mining_info() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - compare_over_blocks( - &services.test_manager, - &services.zcashd_subscriber, - &services.zaino_subscriber, - 10, - |s| async move { s.get_mining_info().await.unwrap() }, - ) - .await; - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_tx_out_set_info() { - get_tx_out_set_info_inner().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_peer_info() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - let zcashd_peer_info = services.zcashd_subscriber.get_peer_info().await.unwrap(); - let zaino_peer_info = services.zaino_subscriber.get_peer_info().await.unwrap(); - - assert_eq!(zcashd_peer_info, zaino_peer_info); - - services.generate_blocks_and_wait_for_tips(1).await; - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_block_subsidy() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - services.generate_blocks_and_wait_for_tips(1).await; - - let zcashd_block_subsidy = services - .zcashd_subscriber - .get_block_subsidy(1) - .await - .unwrap(); - let zaino_block_subsidy = services - .zaino_subscriber - .get_block_subsidy(1) - .await - .unwrap(); - - assert_eq!(zcashd_block_subsidy, zaino_block_subsidy); - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn validate_address() { - validate_address_inner().await; - } - - #[allow(deprecated)] - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn z_validate_address() { - let mut services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - clientless::rpc::z_validate_address::run_z_validate_for(&services.zcashd_subscriber) - .await; - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn z_get_block() { - z_get_block_inner().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_block_header() { - let services = zaino_testutils::launch_zcashd_dual_fetch_services().await; - - const BLOCK_LIMIT: u32 = 10; - - services - .test_manager - .generate_blocks_and_check_each( - BLOCK_LIMIT, - &services.zaino_subscriber, - &services.zcashd_subscriber, - async |i| { - clientless::assert_get_block_header_matches( - &services.zcashd_subscriber, - &services.zaino_subscriber, - i, - ) - .await; - }, - ) - .await; - } - } -} diff --git a/live-tests/clientless/tests/state_service.rs b/live-tests/clientless/tests/state_service.rs index 9ae71eaf..dc0e2e8e 100644 --- a/live-tests/clientless/tests/state_service.rs +++ b/live-tests/clientless/tests/state_service.rs @@ -1,392 +1,15 @@ -use zaino_primitives::types::{rpc::AddressDeltasRequest, TransparentAddress}; - -/// A `getaddressdeltas` request over a height range, from a plain address string. -fn deltas_request(address: &str, start: u32, end: u32, chain_info: bool) -> AddressDeltasRequest { - AddressDeltasRequest::Filtered { - addresses: vec![TransparentAddress::new(address.to_string())], - start, - end, - chain_info, - } -} - -use zaino_state::{LightWalletIndexer, NodeBackedIndexerServiceSubscriber, ZcashIndexer}; -use zaino_testutils::{StateAndFetchServices, ValidatorExt}; -use zaino_testutils::{ValidatorKind, ZEBRAD_THE_PUB_TESTNET_CACHE_DIR}; -use zcash_local_net::validator::zebrad::Zebrad; -use zebra_chain::parameters::NetworkKind; -use zebra_rpc::methods::{GetAddressBalanceRequest, GetAddressTxIdsRequest}; - -/// Launch regtest state+fetch services with no chain cache. -async fn launch_regtest(enable_zaino: bool) -> StateAndFetchServices { - zaino_testutils::launch_state_and_fetch_services::( - &ValidatorKind::Zebrad, - None, - enable_zaino, - Some(NetworkKind::Regtest), - ) - .await -} - -/// Launch state+fetch services against the cached testnet chain (zaino off — -/// the testnet comparisons exercise only the standalone services). -async fn launch_testnet_cached() -> StateAndFetchServices { - zaino_testutils::launch_state_and_fetch_services::( - &ValidatorKind::Zebrad, - ZEBRAD_THE_PUB_TESTNET_CACHE_DIR.clone(), - false, - Some(NetworkKind::Testnet), - ) - .await -} - -/// Assert the fetch- and state-service subscribers agree on a query, and -/// return the agreed value for follow-up assertions. `fetch_query` and -/// `state_query` are the same query spelled once per subscriber type (the two -/// subscribers are different types, so one closure cannot serve both); each -/// takes its subscriber by value (subscribers are `Clone`) so its future owns -/// it. -#[allow(deprecated)] -async fn assert_subscribers_agree( - services: &StateAndFetchServices, - fetch_query: impl FnOnce(NodeBackedIndexerServiceSubscriber) -> FFut, - state_query: impl FnOnce(NodeBackedIndexerServiceSubscriber) -> SFut, -) -> T -where - T: std::fmt::Debug + PartialEq, - FFut: std::future::Future, - SFut: std::future::Future, -{ - let from_fetch = dbg!(fetch_query(services.fetch_subscriber.clone()).await); - let from_state = dbg!(state_query(services.state_subscriber.clone()).await); - assert_eq!(from_fetch, from_state); - from_state -} +use std::time::Duration; -#[allow(deprecated)] -async fn state_service_check_info( - validator: &ValidatorKind, - chain_cache: Option, - network: NetworkKind, -) { - let mut services = zaino_testutils::launch_state_and_fetch_services::( - validator, - chain_cache, - false, - Some(network), - ) - .await; - - if dbg!(network.to_string()) == *"Regtest" { - services.generate_blocks_and_wait_for_tips(1).await; - } - - let fetch_service_info = dbg!(services.fetch_subscriber.get_info().await.unwrap()); - let fetch_service_blockchain_info = dbg!(services - .fetch_subscriber - .get_blockchain_info() - .await - .unwrap()); - - let state_service_info = dbg!(services.state_subscriber.get_info().await.unwrap()); - let state_service_blockchain_info = dbg!(services - .state_subscriber - .get_blockchain_info() - .await - .unwrap()); - - // Clean timestamp from get_info - let cleaned_fetch_info = zaino_testutils::get_info_with_zeroed_timestamp(fetch_service_info); - - let cleaned_state_info = zaino_testutils::get_info_with_zeroed_timestamp(state_service_info); - - assert_eq!(cleaned_fetch_info, cleaned_state_info); - - assert_eq!( - fetch_service_blockchain_info.chain, - state_service_blockchain_info.chain - ); - assert_eq!( - fetch_service_blockchain_info.blocks, - state_service_blockchain_info.blocks - ); - assert_eq!( - fetch_service_blockchain_info.best_block_hash, - state_service_blockchain_info.best_block_hash - ); - assert_eq!( - fetch_service_blockchain_info.estimated_height, - state_service_blockchain_info.estimated_height - ); - // TODO: Fix this! (ignored due to [https://github.com/zingolabs/zaino/issues/235]). - // assert_eq!( - // fetch_service_blockchain_info.value_pools, - // state_service_blockchain_info.value_pools - // ); - assert_eq!( - fetch_service_blockchain_info.upgrades, - state_service_blockchain_info.upgrades - ); - assert_eq!( - fetch_service_blockchain_info.consensus, - state_service_blockchain_info.consensus - ); - - services.test_manager.close().await; -} - -async fn state_service_get_address_balance_testnet() { - let mut services = launch_testnet_cached().await; +use anyhow::{Context, Result}; +use serde_json::{json, Value}; +use zaino_testutils::{assert_json_shape_matches, assert_rpc_parity, ShieldedProtocol}; +use ztest::prelude::*; - let address = "tmAkxrvJCN75Ty9YkiHccqc1hJmGZpggo6i"; - let address_request = GetAddressBalanceRequest::new(vec![address.to_string()]); - let state_request = address_request.clone(); +const READY: Duration = Duration::from_secs(90); - assert_subscribers_agree( - &services, - |f| async move { f.z_get_address_balance(address_request).await.unwrap() }, - |s| async move { s.z_get_address_balance(state_request).await.unwrap() }, - ) - .await; - - services.test_manager.close().await; -} - -async fn state_service_get_block_raw( - validator: &ValidatorKind, - chain_cache: Option, - network: NetworkKind, -) { - let mut services = zaino_testutils::launch_state_and_fetch_services::( - validator, - chain_cache, - false, - Some(network), - ) - .await; - - let height = match network { - NetworkKind::Regtest => "1".to_string(), - _ => "1000000".to_string(), - }; - let state_height = height.clone(); - - assert_subscribers_agree( - &services, - |f| async move { f.z_get_block(height, Some(0)).await.unwrap() }, - |s| async move { s.z_get_block(state_height, Some(0)).await.unwrap() }, - ) - .await; - - services.test_manager.close().await; -} - -async fn state_service_get_block_object( - validator: &ValidatorKind, - chain_cache: Option, - network: NetworkKind, -) { - let mut services = zaino_testutils::launch_state_and_fetch_services::( - validator, - chain_cache, - false, - Some(network), - ) - .await; - - let height = match network { - NetworkKind::Regtest => "1".to_string(), - _ => "1000000".to_string(), - }; - let state_height = height.clone(); - - let block = assert_subscribers_agree( - &services, - |f| async move { f.z_get_block(height, Some(1)).await.unwrap() }, - |s| async move { s.z_get_block(state_height, Some(1)).await.unwrap() }, - ) - .await; - - let hash = match &block { - zebra_rpc::methods::GetBlock::Raw(_) => panic!("expected object"), - zebra_rpc::methods::GetBlock::Object(obj) => obj.hash().to_string(), - }; - let state_service_get_block_by_hash = services - .state_subscriber - .z_get_block(hash.clone(), Some(1)) - .await - .unwrap(); - assert_eq!(state_service_get_block_by_hash, block); - - services.test_manager.close().await; -} - -async fn state_service_get_raw_mempool_testnet() { - let mut services = launch_testnet_cached().await; - - assert_subscribers_agree( - &services, - |f| async move { - let mut mempool = f.get_raw_mempool().await.unwrap(); - mempool.sort(); - mempool - }, - |s| async move { - let mut mempool = s.get_raw_mempool().await.unwrap(); - mempool.sort(); - mempool - }, - ) - .await; - - services.test_manager.close().await; -} - -async fn state_service_z_get_treestate_testnet() { - let mut services = launch_testnet_cached().await; - - assert_subscribers_agree( - &services, - |f| async move { f.z_get_treestate("3000000".to_string()).await.unwrap() }, - |s| async move { s.z_get_treestate("3000000".to_string()).await.unwrap() }, - ) - .await; - - services.test_manager.close().await; -} - -async fn state_service_z_get_subtrees_by_index_testnet() { - let mut services = launch_testnet_cached().await; - - assert_subscribers_agree( - &services, - |f| async move { - f.z_get_subtrees_by_index( - zaino_primitives::types::ShieldedPool::Sapling, - 0.into(), - None, - ) - .await - .unwrap() - }, - |s| async move { - s.z_get_subtrees_by_index( - zaino_primitives::types::ShieldedPool::Sapling, - 0.into(), - None, - ) - .await - .unwrap() - }, - ) - .await; - - assert_subscribers_agree( - &services, - |f| async move { - f.z_get_subtrees_by_index( - zaino_primitives::types::ShieldedPool::Orchard, - 0.into(), - None, - ) - .await - .unwrap() - }, - |s| async move { - s.z_get_subtrees_by_index( - zaino_primitives::types::ShieldedPool::Orchard, - 0.into(), - None, - ) - .await - .unwrap() - }, - ) - .await; - - services.test_manager.close().await; -} - -async fn state_service_get_raw_transaction_testnet() { - let mut services = launch_testnet_cached().await; - - let txid = "abb0399df392130baa45644c421fab553670a2d0d399c4dd776a8f7862ec289d".to_string(); - let state_txid = txid.clone(); - - assert_subscribers_agree( - &services, - |f| async move { f.get_raw_transaction(txid, None).await.unwrap() }, - |s| async move { s.get_raw_transaction(state_txid, None).await.unwrap() }, - ) - .await; - - services.test_manager.close().await; -} - -async fn state_service_get_address_tx_ids_testnet() { - let mut services = launch_testnet_cached().await; - - let address = "tmAkxrvJCN75Ty9YkiHccqc1hJmGZpggo6i"; - let address_request = - GetAddressTxIdsRequest::new(vec![address.to_string()], Some(2000000), Some(3000000)); - let state_request = address_request.clone(); - - assert_subscribers_agree( - &services, - |f| async move { f.get_address_tx_ids(address_request).await.unwrap() }, - |s| async move { s.get_address_tx_ids(state_request).await.unwrap() }, - ) - .await; - - services.test_manager.close().await; -} - -async fn state_service_get_address_utxos_testnet() { - let mut services = launch_testnet_cached().await; - - let address = "tmAkxrvJCN75Ty9YkiHccqc1hJmGZpggo6i"; - let address_request = GetAddressBalanceRequest::new(vec![address.to_string()]); - let state_request = address_request.clone(); - - assert_subscribers_agree( - &services, - |f| async move { f.z_get_address_utxos(address_request).await.unwrap() }, - |s| async move { s.z_get_address_utxos(state_request).await.unwrap() }, - ) - .await; - - services.test_manager.close().await; -} - -async fn state_service_get_address_deltas_testnet() { - let mut services = launch_testnet_cached().await; - - let address = "tmAkxrvJCN75Ty9YkiHccqc1hJmGZpggo6i"; - - // Test simple response - let simple_request = deltas_request(address, 2000000, 3000000, false); - let state_simple_request = simple_request.clone(); - - assert_subscribers_agree( - &services, - |f| async move { f.get_address_deltas(simple_request).await.unwrap() }, - |s| async move { s.get_address_deltas(state_simple_request).await.unwrap() }, - ) - .await; - - // Test response with chain info - let chain_info_params = deltas_request(address, 2000000, 3000000, true); - let state_chain_info_params = chain_info_params.clone(); - - assert_subscribers_agree( - &services, - |f| async move { f.get_address_deltas(chain_info_params).await.unwrap() }, - |s| async move { s.get_address_deltas(state_chain_info_params).await.unwrap() }, - ) - .await; - - services.test_manager.close().await; -} +// The testnet parity suite lives in its own target, `tests/testnet_parity.rs`: +// it shares no topology with the regtest tests here, and consolidating its +// eleven tests into one env per fixture left it self-contained. mod zebra { @@ -395,66 +18,60 @@ mod zebra { pub(crate) mod check_info { use super::*; - use zaino_testutils::ZEBRAD_CHAIN_CACHE_DIR; - use zcash_local_net::validator::zebrad::Zebrad; - - #[tokio::test(flavor = "multi_thread")] - async fn regtest_no_cache() { - state_service_check_info::(&ValidatorKind::Zebrad, None, NetworkKind::Regtest) - .await; - } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn state_service_chaintip_update_subscriber() { - let services = launch_regtest(true).await; - let mut chaintip_subscriber = services - .state_subscriber - .chaintip_update_subscriber() - .expect("the Direct connection exposes a local tip-change stream"); - services - .test_manager - .generate_blocks_and_check_each( - 5, - &services.fetch_subscriber, - &services.state_subscriber, - async |_| { - assert_eq!( - chaintip_subscriber.next_tip_hash().await.unwrap().0, - <[u8; 32]>::try_from( - services - .state_subscriber - .get_latest_block() - .await - .unwrap() - .hash - ) - .unwrap() - ) - }, - ) - .await; - } - - #[tokio::test(flavor = "multi_thread")] - #[ignore = "We no longer use chain caches. See zcashd::check_info::regtest_no_cache."] - async fn regtest_with_cache() { - state_service_check_info::( - &ValidatorKind::Zebrad, - ZEBRAD_CHAIN_CACHE_DIR.clone(), - NetworkKind::Regtest, - ) - .await; - } - - #[ignore = "requires fully synced testnet."] - #[tokio::test(flavor = "multi_thread")] - async fn testnet() { - state_service_check_info::( - &ValidatorKind::Zebrad, - ZEBRAD_THE_PUB_TESTNET_CACHE_DIR.clone(), - NetworkKind::Testnet, - ) - .await; + async fn regtest_no_cache() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + let frpc = fetch.json_rpc().await?; + let srpc = state.json_rpc().await?; + // `errors` carries zebra's latest transient sync warning, which rotates on a + // peerless regtest node, so the two sides sample different text. + assert_rpc_parity("getinfo", "", &frpc, &srpc, &["errorstimestamp", "errors"]).await?; + + // TODO: Fix this! (ignored due to [https://github.com/zingolabs/zaino/issues/235]). + // assert_eq!( + // fetch_service_blockchain_info.value_pools(), + // state_service_blockchain_info.value_pools() + // ); + let fetch_info = frpc.call_value("getblockchaininfo", json!([])).await?; + let state_info = srpc.call_value("getblockchaininfo", json!([])).await?; + assert_json_shape_matches( + "getblockchaininfo", + fetch_info, + state_info, + &[], + &[ + "chain", + "blocks", + "bestblockhash", + "estimatedheight", + "upgrades", + "consensus", + ], + ); + Ok(()) } } @@ -462,424 +79,621 @@ mod zebra { use super::*; - #[ignore = "requires fully synced testnet."] - #[tokio::test(flavor = "multi_thread")] - async fn address_utxos_testnet() { - state_service_get_address_utxos_testnet().await; - } - - #[ignore = "requires fully synced testnet."] + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn address_tx_ids_testnet() { - state_service_get_address_tx_ids_testnet().await; - } - - #[ignore = "requires fully synced testnet."] - #[tokio::test(flavor = "multi_thread")] - async fn raw_transaction_testnet() { - state_service_get_raw_transaction_testnet().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn best_blockhash() { - let services = launch_regtest(true).await; - services.generate_blocks_and_wait_for_tips(2).await; - - assert_subscribers_agree( - &services, - |f| async move { f.get_best_blockhash().await.unwrap() }, - |s| async move { s.get_best_blockhash().await.unwrap() }, + async fn best_blockhash() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(2).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_rpc_parity( + "getbestblockhash", + "", + &fetch.json_rpc().await?, + &state.json_rpc().await?, + &[], ) - .await; + .await?; + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn block_count() { - let mut services = launch_regtest(true).await; - services.generate_blocks_and_wait_for_tips(2).await; - - assert_subscribers_agree( - &services, - |f| async move { f.get_block_count().await.unwrap() }, - |s| async move { s.get_block_count().await.unwrap() }, + async fn block_count() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(2).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_rpc_parity( + "getblockcount", + "", + &fetch.json_rpc().await?, + &state.json_rpc().await?, + &[], ) - .await; - - services.test_manager.close().await; + .await?; + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn mining_info() { - let mut services = launch_regtest(false).await; - - assert_subscribers_agree( - &services, - |f| async move { f.get_mining_info().await.unwrap() }, - |s| async move { s.get_mining_info().await.unwrap() }, - ) - .await; - - services.generate_blocks_and_wait_for_tips(2).await; - - assert_subscribers_agree( - &services, - |f| async move { f.get_mining_info().await.unwrap() }, - |s| async move { s.get_mining_info().await.unwrap() }, - ) - .await; - - services.test_manager.close().await; + async fn mining_info() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let frpc = fetch.json_rpc().await?; + let srpc = state.json_rpc().await?; + let ignore = &["errorstimestamp"]; + assert_rpc_parity("getmininginfo", "", &frpc, &srpc, ignore).await?; + + let tip = validator.generate_blocks(2).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_rpc_parity("getmininginfo", "", &frpc, &srpc, ignore).await?; + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn difficulty() { - let mut services = launch_regtest(true).await; - - assert_subscribers_agree( - &services, - |f| async move { f.get_difficulty().await.unwrap() }, - |s| async move { s.get_difficulty().await.unwrap() }, - ) - .await; - - services.generate_blocks_and_wait_for_tips(2).await; - - assert_subscribers_agree( - &services, - |f| async move { f.get_difficulty().await.unwrap() }, - |s| async move { s.get_difficulty().await.unwrap() }, - ) - .await; - - services.test_manager.close().await; + async fn difficulty() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let frpc = fetch.json_rpc().await?; + let srpc = state.json_rpc().await?; + assert_rpc_parity("getdifficulty", "", &frpc, &srpc, &[]).await?; + + let tip = validator.generate_blocks(2).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_rpc_parity("getdifficulty", "", &frpc, &srpc, &[]).await?; + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn get_network_sol_ps() { - let mut services = launch_regtest(true).await; - services.generate_blocks_and_wait_for_tips(2).await; - - assert_subscribers_agree( - &services, - |f| async move { f.get_network_sol_ps(None, None).await.unwrap() }, - |s| async move { s.get_network_sol_ps(None, None).await.unwrap() }, + async fn get_network_sol_ps() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(2).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_rpc_parity( + "getnetworksolps", + "", + &fetch.json_rpc().await?, + &state.json_rpc().await?, + &[], ) - .await; - - services.test_manager.close().await; + .await?; + Ok(()) } /// A proper test would boot up multiple nodes at the same time, and ask each node /// for information about its peers. In the current state, this test does nothing. + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn peer_info() { - let mut services = launch_regtest(true).await; - services.generate_blocks_and_wait_for_tips(2).await; - - assert_subscribers_agree( - &services, - |f| async move { f.get_peer_info().await.unwrap() }, - |s| async move { s.get_peer_info().await.unwrap() }, + async fn peer_info() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(2).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_rpc_parity( + "getpeerinfo", + "", + &fetch.json_rpc().await?, + &state.json_rpc().await?, + &[], ) - .await; - - services.test_manager.close().await; + .await?; + Ok(()) } mod z { use super::*; - #[allow(deprecated)] + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - pub(crate) async fn z_validate_address() { - let mut services = zaino_testutils::launch_state_and_fetch_services::( - &ValidatorKind::Zebrad, - None, - true, - None, - ) - .await; - - clientless::rpc::z_validate_address::run_z_validate_for(&services.state_subscriber) - .await; - - services.test_manager.close().await; - } - - #[ignore = "requires fully synced testnet."] - #[tokio::test(flavor = "multi_thread")] - pub(crate) async fn subtrees_by_index_testnet() { - state_service_z_get_subtrees_by_index_testnet().await; - } - - #[ignore = "requires fully synced testnet."] - #[tokio::test(flavor = "multi_thread")] - pub(crate) async fn treestate_testnet() { - state_service_z_get_treestate_testnet().await; + pub(crate) async fn z_validate_address() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let _validator = + env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let _fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + clientless::rpc::z_validate_address::run_z_validate_for(&state.json_rpc().await?) + .await } } - #[ignore = "requires fully synced testnet."] - #[tokio::test(flavor = "multi_thread")] - async fn raw_mempool_testnet() { - state_service_get_raw_mempool_testnet().await; - } - + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn block_object_regtest() { - state_service_get_block_object(&ValidatorKind::Zebrad, None, NetworkKind::Regtest) - .await; + async fn block_object_regtest() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + let frpc = fetch.json_rpc().await?; + let srpc = state.json_rpc().await?; + let block = assert_rpc_parity("getblock", r#"["1", 1]"#, &frpc, &srpc, &[]).await?; + let hash = block + .get("hash") + .and_then(Value::as_str) + .context("getblock.hash")?; + let by_hash = srpc.call_value("getblock", json!([hash, 1])).await?; + assert_eq!( + by_hash, block, + "state getblock by-hash must equal by-height" + ); + Ok(()) } - #[ignore = "requires fully synced testnet."] + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn block_object_testnet() { - state_service_get_block_object( - &ValidatorKind::Zebrad, - ZEBRAD_THE_PUB_TESTNET_CACHE_DIR.clone(), - NetworkKind::Testnet, + async fn block_raw_regtest() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_rpc_parity( + "getblock", + r#"["1", 0]"#, + &fetch.json_rpc().await?, + &state.json_rpc().await?, + &[], ) - .await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn block_raw_regtest() { - state_service_get_block_raw(&ValidatorKind::Zebrad, None, NetworkKind::Regtest).await; - } - - #[ignore = "requires fully synced testnet."] - #[tokio::test(flavor = "multi_thread")] - async fn block_raw_testnet() { - state_service_get_block_raw( - &ValidatorKind::Zebrad, - ZEBRAD_THE_PUB_TESTNET_CACHE_DIR.clone(), - NetworkKind::Testnet, - ) - .await; - } - - #[ignore = "requires fully synced testnet."] - #[tokio::test(flavor = "multi_thread")] - async fn address_balance_testnet() { - state_service_get_address_balance_testnet().await; - } - - #[ignore = "requires fully synced testnet."] - #[tokio::test] - async fn address_deltas_testnet() { - state_service_get_address_deltas_testnet().await; + .await?; + Ok(()) } } pub(crate) mod lightwallet_indexer { - use futures::StreamExt as _; - use zaino_proto::proto::service::{BlockId, BlockRange, GetSubtreeRootsArg}; - use super::*; + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn get_latest_block() { - let services = launch_regtest(true).await; - services.generate_blocks_and_wait_for_tips(1).await; - - assert_subscribers_agree( - &services, - |f| async move { f.get_latest_block().await.unwrap() }, - |s| async move { s.get_latest_block().await.unwrap() }, - ) - .await; + async fn get_latest_block() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_eq!( + fetch.latest_block_height().await?, + state.latest_block_height().await?, + "latest block height must agree" + ); + assert_eq!( + fetch.get_block(tip).await?, + state.get_block(tip).await?, + "tip block must agree" + ); + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn get_block() { - let services = launch_regtest(true).await; - services.generate_blocks_and_wait_for_tips(2).await; - - let second_block_by_height = BlockId { - height: 2, - hash: vec![], - }; - let state_block_id = second_block_by_height.clone(); - let block_by_height = assert_subscribers_agree( - &services, - |f| async move { f.get_block(second_block_by_height).await.unwrap() }, - |s| async move { s.get_block(state_block_id).await.unwrap() }, - ) - .await; - - let second_block_by_hash = BlockId { - height: 0, - hash: block_by_height.hash.clone(), - }; - let state_block_id = second_block_by_hash.clone(); - let block_by_hash = assert_subscribers_agree( - &services, - |f| async move { f.get_block(second_block_by_hash).await.unwrap() }, - |s| async move { s.get_block(state_block_id).await.unwrap() }, - ) - .await; - - assert_eq!(block_by_hash, block_by_height) + async fn get_block() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(2).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + let by_height_f = fetch.get_block(BlockHeight::from(2u32)).await?; + let by_height_s = state.get_block(BlockHeight::from(2u32)).await?; + assert_eq!(by_height_f, by_height_s, "get_block(2) must agree"); + + let hash_bytes: [u8; 32] = by_height_f + .hash + .clone() + .try_into() + .ok() + .context("block hash must be 32 bytes")?; + let by_hash_f = fetch.get_block_by_hash(BlockHash(hash_bytes)).await?; + let by_hash_s = state.get_block_by_hash(BlockHash(hash_bytes)).await?; + assert_eq!(by_hash_f, by_hash_s, "get_block by-hash must agree"); + assert_eq!(by_hash_f, by_height_f, "by-hash must equal by-height"); + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn get_block_header() { - let services = launch_regtest(true).await; - - const BLOCK_LIMIT: u32 = 10; - - services - .test_manager - .generate_blocks_and_check_each( - BLOCK_LIMIT, - &services.fetch_subscriber, - &services.state_subscriber, - async |i| { - clientless::assert_get_block_header_matches( - &services.fetch_subscriber, - &services.state_subscriber, - i, - ) - .await; - }, - ) - .await; + async fn get_block_header() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let frpc = fetch.json_rpc().await?; + let srpc = state.json_rpc().await?; + for i in 0u32..10 { + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + let blk = frpc + .call_value("getblock", json!([i.to_string(), 1])) + .await?; + let hash = blk + .get("hash") + .and_then(Value::as_str) + .with_context(|| format!("getblock({i},1).hash missing"))? + .to_string(); + let params = format!(r#"["{hash}", false]"#); + assert_rpc_parity("getblockheader", ¶ms, &frpc, &srpc, &[]).await?; + } + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn get_tree_state() { - let services = launch_regtest(true).await; - services.generate_blocks_and_wait_for_tips(2).await; - - let chain_height = dbg!(services.state_subscriber.chain_height().await.unwrap()).0; - - let treestate_by_height = BlockId { - height: chain_height as u64, - hash: vec![], - }; - let state_block_id = treestate_by_height.clone(); - assert_subscribers_agree( - &services, - |f| async move { f.get_tree_state(treestate_by_height).await.unwrap() }, - |s| async move { s.get_tree_state(state_block_id).await.unwrap() }, - ) - .await; + async fn get_tree_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(2).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_eq!( + fetch.get_tree_state(tip).await?, + state.get_tree_state(tip).await?, + "tree state at tip must agree" + ); + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn get_subtree_roots() { - let services = launch_regtest(true).await; - services.generate_blocks_and_wait_for_tips(5).await; - - let sapling_subtree_roots_request = GetSubtreeRootsArg { - start_index: 2, - shielded_protocol: 0, - max_entries: 0, - }; - assert_subscribers_agree( - &services, - |f| async move { - f.get_subtree_roots(sapling_subtree_roots_request) - .await - .unwrap() - .map(Result::unwrap) - .collect::>() - .await - }, - |s| async move { - s.get_subtree_roots(sapling_subtree_roots_request) - .await - .unwrap() - .map(Result::unwrap) - .collect::>() - .await - }, - ) - .await; + async fn get_subtree_roots() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(5).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_eq!( + fetch + .get_subtree_roots(2, ShieldedProtocol::Sapling, 0) + .await?, + state + .get_subtree_roots(2, ShieldedProtocol::Sapling, 0) + .await?, + "sapling subtree roots must agree" + ); + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn get_latest_tree_state() { - let services = launch_regtest(true).await; - services.generate_blocks_and_wait_for_tips(2).await; - - assert_subscribers_agree( - &services, - |f| async move { f.get_latest_tree_state().await.unwrap() }, - |s| async move { s.get_latest_tree_state().await.unwrap() }, - ) - .await; - } - - async fn get_block_range_helper(nullifiers_only: bool) { - let services = launch_regtest(true).await; - services.generate_blocks_and_wait_for_tips(6).await; - - let start_height: u64 = 2; - let end_height: u64 = 5; - if nullifiers_only { - let request = BlockRange { - start: Some(BlockId { - height: start_height, - hash: vec![], - }), - end: Some(BlockId { - height: end_height, - hash: vec![], - }), - pool_types: zaino_testutils::all_pools_i32(), - }; - let state_request = request.clone(); - // TODO(#1088): replace deprecated nullifier-range client usage. - #[allow(deprecated)] - assert_subscribers_agree( - &services, - |f| async move { - f.get_block_range_nullifiers(request) - .await - .unwrap() - .map(Result::unwrap) - .collect::>() - .await - }, - |s| async move { - s.get_block_range_nullifiers(state_request) - .await - .unwrap() - .map(Result::unwrap) - .collect::>() - .await - }, - ) - .await; - } else { - let pools = zaino_testutils::all_pools_i32(); - let fetch_service_get_block_range = zaino_testutils::collect_block_range( - &services.fetch_subscriber, - start_height, - end_height, - pools.clone(), - ) - .await; - let state_service_get_block_range = zaino_testutils::collect_block_range( - &services.state_subscriber, - start_height, - end_height, - pools, - ) - .await; - assert_eq!(fetch_service_get_block_range, state_service_get_block_range); - } + async fn get_latest_tree_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(2).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + assert_eq!( + fetch.get_latest_tree_state().await?, + state.get_latest_tree_state().await?, + "latest tree state must agree" + ); + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn get_block_range_full() { - get_block_range_helper(false).await; + async fn get_block_range_full() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(6).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + let all_pools = vec![1, 2, 3, 4]; + assert_eq!( + fetch + .get_block_range_with_pools( + BlockHeight::from(2u32), + BlockHeight::from(5u32), + all_pools.clone(), + ) + .await?, + state + .get_block_range_with_pools( + BlockHeight::from(2u32), + BlockHeight::from(5u32), + all_pools, + ) + .await?, + "block range [2,5] must agree" + ); + Ok(()) } + #[ztest::qos::integration(footprint = "3c/6Gi")] #[tokio::test(flavor = "multi_thread")] - async fn get_block_range_nullifiers() { - get_block_range_helper(true).await; + async fn get_block_range_nullifiers() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mount(&vol)); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let tip = validator.generate_blocks(6).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + // TODO(#1088): replace deprecated nullifier-range client usage. + assert_eq!( + fetch + .get_block_range_nullifiers(BlockHeight::from(2u32), BlockHeight::from(5u32)) + .await?, + state + .get_block_range_nullifiers(BlockHeight::from(2u32), BlockHeight::from(5u32)) + .await?, + "block range nullifiers [2,5] must agree" + ); + Ok(()) } } } diff --git a/live-tests/clientless/tests/test_vectors.rs b/live-tests/clientless/tests/test_vectors.rs index 90b86c81..9fe801fd 100644 --- a/live-tests/clientless/tests/test_vectors.rs +++ b/live-tests/clientless/tests/test_vectors.rs @@ -1,12 +1,18 @@ -//! Holds code used to build test vector data for unit tests. These tests should not be run by default or in CI. +//! Parses committed pre-v4 transaction vectors through `legacy_parser`, the +//! independent second parser, and asserts the fields it recovers. +//! +//! Offline: no validator, no cluster. It sits in `clientless` because that is +//! where the vectors have always lived, not because it needs the harness. use anyhow::Context; use wire_serialized_transaction_test_data::transactions::get_test_vectors; use zaino_testutils::legacy_parser::transaction::FullTransaction; use zaino_testutils::legacy_parser::utils::ParseFromSlice; -#[tokio::test(flavor = "multi_thread")] -async fn pre_v4_txs_parsing() -> anyhow::Result<()> { +// Synchronous: nothing here awaits. The parser is offline, so this test needs +// neither a runtime nor the ztest cluster. +#[test] +fn pre_v4_txs_parsing() -> anyhow::Result<()> { let test_vectors = get_test_vectors(); for (i, test_vector) in test_vectors.iter().filter(|v| v.version < 4).enumerate() { diff --git a/live-tests/clientless/tests/the_pub_testnet_ironwood_boundary.rs b/live-tests/clientless/tests/the_pub_testnet_ironwood_boundary.rs deleted file mode 100644 index 6ef2496e..00000000 --- a/live-tests/clientless/tests/the_pub_testnet_ironwood_boundary.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Observational walk of the real NU6.3 activation boundary on The Public -//! Testnet (glossary: The Public Testnet is the public test network and -//! nothing else). Historical blocks are permanent, so these invariants are -//! checkable forever, long after the epoch that produced them closed. -//! -//! The invariants, enforced one block below the boundary and at it: -//! -//! - Below the activation height the Ironwood pool holds no value. -//! - From the activation height the Orchard pool's value never increases: -//! the no-new-value rule admits only withdrawals and same-receiver change -//! (the cross-address restriction, -//! ). -//! -//! The activation height itself is read from the running validator's -//! `getblockchaininfo.upgrades` — the single source of truth for activation -//! heights — and cross-checked against the observed activation on The -//! Public Testnet at height 4,134,000 (~2026-07-04). -//! -//! Non-hermetic and therefore env-gated: the test needs a zebrad chain -//! cache at `~/.cache/zebra` (`ZEBRAD_THE_PUB_TESTNET_CACHE_DIR`) synced past the -//! activation height, and skips with a message when the cache is absent or -//! short. Run it manually, or from a job that maintains the cache. -//! -//! The validator is launched through `ZebradConfig` directly rather than -//! `TestManager`: the manager's launch path always writes regtest test -//! parameters into the config, while this test needs The Public Testnet -//! (`network_type: NetworkType::Testnet`, which makes zebrad ignore -//! `activation_heights` and `miner_address`). - -use zaino_testutils::{ValidatorOracle, ZEBRAD_THE_PUB_TESTNET_CACHE_DIR}; -use zcash_local_net::process::Process as _; -use zcash_local_net::protocol::NetworkType; -use zcash_local_net::validator::zebrad::{Zebrad, ZebradConfig}; -use zcash_local_net::validator::Validator as _; -use zebra_chain::parameters::NetworkUpgrade; - -/// The height The Public Testnet activated NU6.3 at, recorded from the real -/// activation. A mismatch means either a reset of The Public Testnet or a -/// wrong validator pin — both worth failing loudly over. -const OBSERVED_NU6_3_ACTIVATION_ON_THE_PUB_TESTNET: u32 = 4_134_000; - -/// The chain value of `pool_id` as of `height`, from the validator's -/// verbosity-2 block object. -async fn pool_zats(connector: &ValidatorOracle, height: u32, pool_id: &str) -> i64 { - let block = connector - .call( - "getblock", - vec![serde_json::json!(height.to_string()), serde_json::json!(2)], - ) - .await; - - block["valuePools"] - .as_array() - .expect("verbosity-2 block object carries value pools") - .iter() - .find(|pool| pool["id"] == pool_id) - .unwrap_or_else(|| panic!("value pools must include {pool_id}"))["chainValueZat"] - .as_i64() - .expect("a pool's chain value is an integer number of zatoshis") -} - -/// multi_thread required: the test launches the validator process and polls -/// it over RPC. -#[tokio::test(flavor = "multi_thread")] -async fn value_pools_respect_the_boundary_on_the_pub_testnet() { - let Some(cache_dir) = ZEBRAD_THE_PUB_TESTNET_CACHE_DIR.clone() else { - eprintln!("skipping: no cache dir configured for The Public Testnet"); - return; - }; - if !cache_dir.exists() { - eprintln!( - "skipping: no zebrad chain cache of The Public Testnet at {}", - cache_dir.display() - ); - return; - } - - let config = ZebradConfig { - network_type: NetworkType::Testnet, - chain_cache: Some(cache_dir), - ..ZebradConfig::default() - }; - let mut zebrad = Zebrad::launch(config) - .await - .expect("launch a zebrad on The Public Testnet"); - - let rpc_address = format!("127.0.0.1:{}", zebrad.get_port()); - zaino_rpc::probe_node(&rpc_address, None, None, None) - .await - .expect("validator RPC reachable"); - let connector = ValidatorOracle::new(&rpc_address); - - // Read as zebra's own response type: the boundary below is found by - // matching on the `NetworkUpgrade` enum. - let blockchain_info: zebra_rpc::methods::GetBlockchainInfoResponse = - serde_json::from_value(connector.get("getblockchaininfo").await) - .expect("getblockchaininfo"); - - // The validator's reported schedule is the source of truth for the - // boundary; the recorded constant pins the real history of The Public Testnet. - let boundary = blockchain_info - .upgrades() - .values() - .find_map(|upgrade_info| { - let (upgrade, height, _status) = upgrade_info.into_parts(); - (upgrade == NetworkUpgrade::Nu6_3).then_some(height.0) - }) - .expect("the validator on The Public Testnet must report an NU6.3 activation height"); - assert_eq!( - boundary, OBSERVED_NU6_3_ACTIVATION_ON_THE_PUB_TESTNET, - "the validator's NU6.3 height must match the observed activation on The Public Testnet" - ); - - let tip = blockchain_info.blocks().0; - if tip <= boundary { - eprintln!( - "skipping: cache tip {tip} of The Public Testnet has not crossed the NU6.3 boundary {boundary}" - ); - zebrad.stop(); - return; - } - - // One block below the boundary and at it, plus a block of margin on - // each side: the Ironwood pool holds no value below the activation - // height, and the Orchard pool never grows from it. - for height in (boundary - 2)..boundary { - assert_eq!( - pool_zats(&connector, height, "ironwood").await, - 0, - "the ironwood pool must hold no value at height {height}, below the boundary" - ); - } - let walk_end = boundary + 1; - let mut previous_orchard = pool_zats(&connector, boundary - 1, "orchard").await; - for height in boundary..=walk_end { - let orchard = pool_zats(&connector, height, "orchard").await; - assert!( - orchard <= previous_orchard, - "the orchard pool must never grow from the boundary; \ - height {height} holds {orchard} zats after {previous_orchard}" - ); - previous_orchard = orchard; - } - - zebrad.stop(); -} diff --git a/live-tests/clientless/tests/validator_heights.rs b/live-tests/clientless/tests/validator_heights.rs index 580e71c8..72302cda 100644 --- a/live-tests/clientless/tests/validator_heights.rs +++ b/live-tests/clientless/tests/validator_heights.rs @@ -22,38 +22,15 @@ //! The mapping from that shape to adopted heights is unit-tested next to //! `activation_heights_from_upgrades` in zaino-state. -use zaino_state::ZcashIndexer as _; -use zaino_testutils::{ - all_pools_i32, collect_block_range, MinerPool, Rpc, TestManager, ValidatorKind, - NU6_3_TRANSITION_BOUNDARY, ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS, - ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS, -}; -use zcash_local_net::validator::zebrad::Zebrad; - -/// Launches an orchard-receiver-mining zebrad on the transition schedule -/// with zainod enabled. The harness hands zainod only the canonical -/// placeholder (see `launch_mining_to`), so the launch itself is the -/// deliberate config/validator misalignment under test. -async fn launch_transition_validator() -> TestManager { - assert_ne!( - ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS, ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS, - "premise: zainod's config placeholder must differ from the fixture \ - schedule, or this proves nothing" - ); +use std::time::Duration; - TestManager::::launch_mining_to( - MinerPool::Orchard, - &ValidatorKind::Zebrad, - None, - Some(ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS), - None, - true, - false, - false, - ) - .await - .expect("launch TestManager") -} +use anyhow::{Context, Result}; +use serde_json::{json, Value}; +use ztest::prelude::*; + +const READY: Duration = Duration::from_secs(120); + +const NU6_3_TRANSITION_BOUNDARY: u32 = 6; /// Boundary sync + no-recompile proof: a kind-only-configured zainod adopts /// the NU6.3-at-6 schedule from the validator, syncs across the boundary, @@ -61,28 +38,56 @@ async fn launch_transition_validator() -> TestManager { /// /// multi_thread required: the test manager spawns the validator and indexer /// services. +#[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] -async fn zainod_syncs_a_schedule_its_config_never_saw() { - let mut test_manager = launch_transition_validator().await; - let subscriber = test_manager.subscriber().clone(); +async fn zainod_syncs_a_schedule_its_config_never_saw() -> Result<()> { + let heights = ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(); + assert_ne!( + heights, + ActivationHeights::regtest_default(), + "premise: zainod's config placeholder must differ from the fixture schedule, \ + or this proves nothing" + ); + + let mut env = TestEnv::builder() + .ready_timeout(READY) + .activation_heights(heights); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(Pool::Orchard)); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; // Two blocks past the boundary, so both eras carry more than one block. // Reaching the tip at all is the core regression: pre-adoption, the // chain-index sync died on the first block whose commitment scheme the // misconfigured heights got wrong. - test_manager - .generate_blocks_and_wait_for_tip(NU6_3_TRANSITION_BOUNDARY + 1, &subscriber) - .await; - let tip = u64::from(subscriber.chain_height().await.expect("chain height").0); + let tip = validator + .generate_blocks(NU6_3_TRANSITION_BOUNDARY + 1) + .await?; + indexer.wait_for_block_num(tip, READY).await?; + let indexed_tip = u64::from(indexer.latest_block_height().await?); assert!( - tip > u64::from(NU6_3_TRANSITION_BOUNDARY), - "sync must cross the boundary, tip is {tip}" + indexed_tip > u64::from(NU6_3_TRANSITION_BOUNDARY), + "the indexer's own tip must be past the boundary, it is {indexed_tip}" ); // Era composition of the served chain proves the adopted schedule is the // validator's, not the placeholder: under the placeholder (NU6.3 at 2) // the pre-boundary orchard coinbases would be misread as ironwood-era. - let blocks = collect_block_range(&subscriber, 2, tip, all_pools_i32()).await; + let blocks = indexer + .get_block_range(BlockHeight::from(2u32), tip) + .await?; + assert!(!blocks.is_empty(), "no compact blocks served"); for block in &blocks { let height = block.height; let has_orchard = block.vtx.iter().any(|tx| !tx.actions.is_empty()); @@ -100,7 +105,7 @@ async fn zainod_syncs_a_schedule_its_config_never_saw() { } } - test_manager.close().await; + Ok(()) } /// The input contract for adoption: the `upgrades` map a live zebrad reports @@ -111,47 +116,81 @@ async fn zainod_syncs_a_schedule_its_config_never_saw() { /// /// multi_thread required: the test manager spawns the validator and indexer /// services. +#[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] -async fn getblockchaininfo_reports_the_configured_schedule() { - use zebra_chain::parameters::NetworkUpgrade; - - let mut test_manager = launch_transition_validator().await; - - // Read as zebra's own response type: this test asserts on the - // `NetworkUpgrade` enum, which is what indexes an activation schedule. - let blockchain_info: zebra_rpc::methods::GetBlockchainInfoResponse = serde_json::from_value( - test_manager - .full_node_jsonrpc_connector() - .await - .get("getblockchaininfo") - .await, - ) - .expect("getblockchaininfo"); - - let reported: Vec<(NetworkUpgrade, u32)> = blockchain_info - .upgrades() - .values() - .map(|upgrade_info| { - let (upgrade, height, _status) = upgrade_info.into_parts(); - (upgrade, height.0) - }) - .collect(); +async fn getblockchaininfo_reports_the_configured_schedule() -> Result<()> { + let heights = ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(); + assert_ne!( + heights, + ActivationHeights::regtest_default(), + "premise: zainod's config placeholder must differ from the fixture schedule, \ + or this proves nothing" + ); + + let mut env = TestEnv::builder() + .ready_timeout(READY) + .activation_heights(heights); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(Pool::Orchard)); + env.build().await?; + + let blockchain_info = validator + .json_rpc() + .await? + .call_value("getblockchaininfo", json!([])) + .await?; + let upgrades = blockchain_info + .get("upgrades") + .and_then(Value::as_object) + .context("getblockchaininfo must carry an upgrades object")?; + + // zebra emits `upgrades` in activation order (keyed by consensus branch id) and + // serde_json's `preserve_order` keeps it after parsing, so comparing ordered `Vec`s + // pins the upgrade set, their heights, and their order. + let mut reported: Vec<(String, u64)> = Vec::new(); + for entry in upgrades.values() { + let name = entry + .get("name") + .and_then(Value::as_str) + .context("each upgrade entry must carry a name")? + .to_string(); + let height = entry + .get("activationheight") + .and_then(Value::as_u64) + .context("each upgrade entry must carry an activationheight")?; + reported.push((name, height)); + } + + let expected: Vec<(String, u64)> = [ + ("Overwinter", 1), + ("Sapling", 1), + ("Blossom", 1), + ("Heartwood", 1), + ("Canopy", 1), + ("NU5", 2), + ("NU6", 2), + ("NU6.1", 2), + ("NU6.2", 2), + ("NU6.3", u64::from(NU6_3_TRANSITION_BOUNDARY)), + ] + .into_iter() + .map(|(name, height)| (name.to_string(), height)) + .collect(); assert_eq!( - reported, - vec![ - (NetworkUpgrade::Overwinter, 1), - (NetworkUpgrade::Sapling, 1), - (NetworkUpgrade::Blossom, 1), - (NetworkUpgrade::Heartwood, 1), - (NetworkUpgrade::Canopy, 1), - (NetworkUpgrade::Nu5, 2), - (NetworkUpgrade::Nu6, 2), - (NetworkUpgrade::Nu6_1, 2), - (NetworkUpgrade::Nu6_2, 2), - (NetworkUpgrade::Nu6_3, 6), - ], + reported, expected, + "reported upgrade schedule must match the pinned transition set, order, and heights" ); - test_manager.close().await; + Ok(()) } diff --git a/live-tests/e2e/Cargo.toml b/live-tests/e2e/Cargo.toml index 73d7a28c..efd6a8e7 100644 --- a/live-tests/e2e/Cargo.toml +++ b/live-tests/e2e/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "e2e" -description = "End-to-end live tests: a zcash-devtool wallet client driving Zaino through its gRPC surface to a live validator." +description = "End-to-end live tests: an in-process zingo wallet client driving Zaino over its gRPC surface to a live validator, on the ztest k8s harness." version = "0.2.0" authors.workspace = true repository.workspace = true @@ -12,57 +12,15 @@ publish = false [features] default = [] -# Support for the zcashd test validator. Opt-in (default off, docs/adr/0005); being deprecated. Gates -# the zcashd-backed wallet tests and forwards to the zaino crates. See -# docs/adr/0001-zcashd-support-feature-gate.md. -zcashd_support = [ - "zaino-testutils/zcashd_support", - "zainod/zcashd_support", -] - -# Single non-default gate for every devtool-incompatible / heavy test (the -# finalization heavies, send_to_all, monitor_unverified_mempool, the -# test-vector builder, address_deltas). They compile by default but are -# `#[ignore]`'d unless this feature is on; run them with -# `--features devtool-incompatible`. Each gated test's doc comment explains -# why it's here. -devtool-incompatible = [] - [dependencies] -# Zaino test harness + packages, sourced from the root workspace manifest. -# default-features = false (carried by the workspace entries) propagates the -# zcashd_support drop; re-enabled by forwarding above. See docs/adr/0001. -zaino-testutils = { workspace = true } -zaino-state = { workspace = true, features = ["test_dependencies"] } -zaino-common = { workspace = true } -zaino-proto = { workspace = true } -zainod = { workspace = true } - -# zcash_local_net drives the zcash-devtool wallet client. -zcash_local_net = { workspace = true } - -# Zebra -zebra-chain = { workspace = true } -zebra-rpc = { workspace = true } -zebra-state = { workspace = true } - -# Runtime / misc -tokio = { workspace = true } -zcash_primitives = { workspace = true } -futures = { workspace = true } -hex = { workspace = true } -corez = { workspace = true } -tower = { workspace = true } +ztest = { workspace = true } +anyhow = { workspace = true } serde_json = { workspace = true } -zaino-consensus.workspace = true [dev-dependencies] -zaino-primitives.workspace = true -# The served JSON schema. These tests compare Zaino's domain answers against a -# raw JSON-RPC oracle, so they render the domain side through the same wire -# types the server uses. -zaino-serve = { workspace = true } -# Enable the tractable seam depth (fast-test-seam) so this crate's live tests can -# exercise finalisation/eviction at small chain heights. Dev-dependency only — the -# feature must never reach a shipped build (see the zaino-state feature comment). -zaino-state = { workspace = true, features = ["fast-test-seam"] } +zaino-testutils = { workspace = true } +ztest = { workspace = true } +anyhow = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +rstest = { workspace = true } diff --git a/live-tests/e2e/src/devtool.rs b/live-tests/e2e/src/devtool.rs deleted file mode 100644 index 7e8f2d7f..00000000 --- a/live-tests/e2e/src/devtool.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! zcash-devtool-backed wallet clients: the in-progress replacement for the -//! zingolib lightclients in [`crate::Clients`] -//! (zingolabs/infrastructure#269). -//! -//! [`DevtoolClients`] mirrors [`crate::Clients`]' method names one-for-one so -//! tests can swap backends mechanically. The clients are managed by -//! zcash_local_net's [`zcash_local_net::wallet`] module: each wallet -//! operation is a run-to-completion `zcash-devtool` subprocess invocation -//! (the binary must be built with `--features regtest_support` and be -//! locatable via `TEST_BINARIES_DIR`/`PATH`). -//! -//! # Known gaps vs the zingolib backend -//! -//! - **Unconfirmed (mempool) balances**: devtool sync is block-based; -//! `monitor_unverified_mempool` cannot swap backends. -//! - **No transaction listing**: the `transaction_summaries` asserts in -//! `get_address_utxos{,_stream}` need raw-gRPC / demotion treatment before -//! their tests swap. (`do_info` is covered now: see -//! [`DevtoolClients::get_info_faucet`].) -//! - **Fee constants**: ZIP-317 applies on both backends, but asserted -//! constants derived from zingolib note selection (e.g. 235_000 after -//! shielding 250_000) must be re-verified on first devtool runs. - -use zcash_local_net::wallet::{ - zcash_devtool::{ZcashDevtool, ZcashDevtoolConfig}, - AddressReceiver, GetInfo, Wallet as _, WalletBalance, -}; -use zcash_primitives::transaction::TxId; - -use crate::Pool; - -/// Holds devtool wallet clients for wallet-to-validator tests: the faucet -/// (mining rewards are received here) and the recipient. -pub struct DevtoolClients { - /// Faucet wallet (abandon-art mnemonic — owns the miner addresses). - pub faucet: ZcashDevtool, - /// Recipient wallet (HOSPITAL_MUSEUM mnemonic). - pub recipient: ZcashDevtool, -} - -/// Launch faucet + recipient devtool wallets against a running Zaino gRPC -/// listener, deriving the wallet network from the running `validator`. That -/// derivation is the only construction the client API offers -/// (infrastructure ADR 0003: the Validator is the single source of truth -/// for activation heights), so wallet/validator height drift is -/// unrepresentable — the wallets follow whatever schedule the validator was -/// launched with, fixture or canonical. The devtool analogue of -/// [`crate::build_clients`]; Zaino must already be serving (wallet -/// initialization fetches the chain tip and birthday tree state from it). -pub async fn build_clients( - zaino_grpc_listen_port: u16, - validator: &V, -) -> DevtoolClients { - let network = zcash_local_net::wallet::WalletNetwork::from_validator(validator).await; - - let mut faucet_config = ZcashDevtoolConfig::faucet(network); - faucet_config.indexer_port = zaino_grpc_listen_port; - let faucet = ZcashDevtool::launch(faucet_config) - .await - .expect("launch devtool faucet wallet"); - - let mut recipient_config = ZcashDevtoolConfig::recipient(network); - recipient_config.indexer_port = zaino_grpc_listen_port; - let recipient = ZcashDevtool::launch(recipient_config) - .await - .expect("launch devtool recipient wallet"); - - DevtoolClients { faucet, recipient } -} - -/// Convert a devtool txid — the hex string `send`/`shield` return, which -/// devtool prints in display (reversed) order via `TxId`'s `Display` — into -/// the internal-order 32 bytes that zaino's `TxFilter` and compact-tx -/// comparisons use (the order zingolib's `TxId::as_ref()` yields). Any test -/// that then queries zaino with the result validates the order: a wrong one -/// simply fails to match the indexed transaction. -pub fn txid_internal_bytes(devtool_txid_hex: &str) -> Vec { - let mut bytes = hex::decode(devtool_txid_hex.trim()).expect("devtool txid is valid hex"); - bytes.reverse(); - bytes -} - -/// [`txid_internal_bytes`] as a [`TxId`], for asserting on compact-block -/// contents (e.g. [`crate::assert_pool_present`]). -pub fn txid_from_devtool(devtool_txid_hex: &str) -> TxId { - let bytes: [u8; 32] = txid_internal_bytes(devtool_txid_hex) - .try_into() - .expect("devtool txid is 32 bytes"); - TxId::from_bytes(bytes) -} - -impl DevtoolClients { - /// The address of `client` that routes funds into `pool`, read from the - /// wallet's default unified address (`"transparent"`/`"sapling"` emit the - /// bare receiver, `"unified"`/`"orchard"` the unified/orchard-only - /// address). Shared by [`DevtoolClients::get_faucet_address`] and - /// [`DevtoolClients::get_recipient_address`]. - async fn address(client: &ZcashDevtool, who: &str, pool: &str) -> String { - let receiver = match pool { - "transparent" => AddressReceiver::Transparent, - "sapling" => AddressReceiver::Sapling, - "unified" => AddressReceiver::Unified, - "orchard" => AddressReceiver::Orchard, - other => panic!("unknown pool address kind {other:?} for {who}"), - }; - client - .address(receiver) - .await - .unwrap_or_else(|e| panic!("address({pool}) for {who}: {e:?}")) - } - - /// The faucet address that routes funds into `pool` - /// (`"transparent" | "sapling" | "unified"`). For the faucet (the miner's - /// wallet), the transparent address is the one the miner pays coinbase to. - pub async fn get_faucet_address(&self, pool: &str) -> String { - Self::address(&self.faucet, "faucet", pool).await - } - - /// The recipient address that routes funds into `pool` - /// (`"transparent" | "sapling" | "unified"`). - pub async fn get_recipient_address(&self, pool: &str) -> String { - Self::address(&self.recipient, "recipient", pool).await - } - - /// The faucet's balance snapshot. Sync first; this reads the local - /// wallet database. - pub async fn faucet_balance(&self) -> WalletBalance { - Self::balance(&self.faucet, "faucet").await - } - - /// The recipient's balance snapshot. Sync first; this reads the local - /// wallet database. - pub async fn recipient_balance(&self) -> WalletBalance { - Self::balance(&self.recipient, "recipient").await - } - - async fn balance(client: &ZcashDevtool, who: &str) -> WalletBalance { - client - .balance() - .await - .unwrap_or_else(|e| panic!("balance for {who}: {e:?}")) - } - - /// The faucet wallet's server/chain info (devtool `wallet get-info`). - /// The connect smoke test only asserts the call succeeds. - pub async fn get_info_faucet(&self) -> GetInfo { - Self::get_info(&self.faucet, "faucet").await - } - - /// The recipient wallet's server/chain info (devtool `wallet get-info`). - pub async fn get_info_recipient(&self) -> GetInfo { - Self::get_info(&self.recipient, "recipient").await - } - - async fn get_info(client: &ZcashDevtool, who: &str) -> GetInfo { - client - .get_info() - .await - .unwrap_or_else(|e| panic!("get_info for {who}: {e:?}")) - } - - /// Send `amount` zatoshis from `client` to `address`. Shared by - /// [`DevtoolClients::send_from_faucet`] and - /// [`DevtoolClients::send_from_recipient`]. Returns the broadcast txid - /// as a hex string (the zingolib backend returns `NonEmpty`; - /// callers that compare txids adapt at the call site). - async fn send(client: &ZcashDevtool, who: &str, address: &str, amount: u64) -> String { - client - .send(address, amount) - .await - .unwrap_or_else(|e| panic!("send from {who}: {e:?}")) - } - - /// Send `amount` zatoshis from the faucet to `address`, returning the - /// txid hex of the broadcast (unmined) transaction. - pub async fn send_from_faucet(&mut self, address: &str, amount: u64) -> String { - Self::send(&self.faucet, "faucet", address, amount).await - } - - /// Send `amount` zatoshis from the recipient to `address`, returning the - /// txid hex of the broadcast (unmined) transaction. - pub async fn send_from_recipient(&mut self, address: &str, amount: u64) -> String { - Self::send(&self.recipient, "recipient", address, amount).await - } - - /// Shield `client`'s transparent funds into orchard. Shared by - /// [`DevtoolClients::shield_faucet`] and - /// [`DevtoolClients::shield_recipient`]. - async fn shield(client: &ZcashDevtool, who: &str) { - client - .shield() - .await - .unwrap_or_else(|e| panic!("shield {who}: {e:?}")); - } - - /// Shield the faucet's transparent funds into orchard. - pub async fn shield_faucet(&mut self) { - Self::shield(&self.faucet, "faucet").await; - } - - /// Shield the recipient's transparent funds into orchard. - pub async fn shield_recipient(&mut self) { - Self::shield(&self.recipient, "recipient").await; - } - - /// Sync `client`'s wallet to the chain tip. Shared by - /// [`DevtoolClients::sync_faucet`] and [`DevtoolClients::sync_recipient`]. - async fn sync(client: &ZcashDevtool, who: &str) { - client - .sync() - .await - .unwrap_or_else(|e| panic!("sync {who}: {e:?}")); - } - - /// Sync the faucet wallet to the chain tip. - pub async fn sync_faucet(&mut self) { - Self::sync(&self.faucet, "faucet").await; - } - - /// Sync the recipient wallet to the chain tip. - pub async fn sync_recipient(&mut self) { - Self::sync(&self.recipient, "recipient").await; - } - - /// Forget all of the recipient wallet's state, then sync from scratch. - /// - /// Unlike the zingolib backend, the rebuilt view contains only mined - /// history — devtool sync does not scan the mempool, so unmined - /// transactions will not reappear (see module docs). - pub async fn rescan_recipient(&mut self) { - self.recipient - .rescan() - .await - .unwrap_or_else(|e| panic!("rescan recipient: {e:?}")); - self.sync_recipient().await; - } -} - -impl Pool { - /// The spendable balance received in this pool, in zatoshis — the - /// devtool-backend counterpart of [`Pool::received_balance`]. Spendable - /// equals received once the funding transaction is mined and the wallet - /// synced, which is the state every asserting test establishes first. - pub fn spendable_balance(self, balance: &WalletBalance) -> u64 { - match self { - Pool::Orchard => balance.orchard_spendable, - Pool::Ironwood => balance.ironwood_spendable, - Pool::Sapling => balance.sapling_spendable, - Pool::Transparent => balance.transparent_spendable, - } - } -} - -/// Launch a shielded-mining validator of `validator` kind (at `activation_heights`, -/// or the kind's defaults on `None`) with Zaino serving gRPC, and build the devtool -/// faucet/recipient wallets against it, without mining or syncing. The shared body -/// of the per-validator `launch_*_and_build_clients` preambles in the devtool test -/// binaries. -pub async fn launch_and_build_devtool_clients( - validator: &zaino_testutils::ValidatorKind, - activation_heights: Option, -) -> (zaino_testutils::TestManager, DevtoolClients) -where - V: zaino_testutils::ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let test_manager = zaino_testutils::TestManager::::launch_mining_to( - zaino_testutils::SHIELDED_FUNDING_POOL, - validator, - None, // network -> Regtest - activation_heights, - None, // no chain cache: build fresh - true, // enable zaino - false, // no json-rpc server - false, // no clients (the devtool wallet is built separately) - ) - .await - .expect("launch TestManager"); - - let clients = build_clients( - test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &test_manager.local_net, - ) - .await; - - (test_manager, clients) -} - -/// The faucet sends 250_000 zatoshis to the recipient's `pool` address, the send is -/// mined in, and the recipient's synced wallet shows the receipt in that pool. -/// Shared body of the per-validator `send_to_pool` tests; the caller launches and -/// funds the faucet first. -pub async fn assert_send_to_pool( - mut test_manager: zaino_testutils::TestManager, - mut clients: DevtoolClients, - pool: Pool, -) where - V: zaino_testutils::ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let recipient = clients.get_recipient_address(pool.address_kind()).await; - let txid = clients.send_from_faucet(&recipient, 250_000).await; - dbg!(txid); - - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_recipient().await; - - assert_eq!( - pool.spendable_balance(&clients.recipient_balance().await), - 250_000 - ); - - test_manager.close().await; -} - -/// The recipient receives a transparent send, confirms it, shields it, and confirms -/// the shielded balance net of the ZIP-317 fee (250_000 − 15_000 = 235_000) in -/// `shielded_pool` — [`Pool::Ironwood`] under NU6.3-era heights (devtool routes -/// `shield` there), [`Pool::Orchard`] under pre-NU6.3 heights. Shared body of the -/// per-validator `shield` tests; the caller launches and funds the faucet first. -pub async fn assert_shield_for_validator( - mut test_manager: zaino_testutils::TestManager, - mut clients: DevtoolClients, - shielded_pool: Pool, -) where - V: zaino_testutils::ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let recipient_taddr = clients.get_recipient_address("transparent").await; - clients.send_from_faucet(&recipient_taddr, 250_000).await; - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_recipient().await; - - assert_eq!( - Pool::Transparent.spendable_balance(&clients.recipient_balance().await), - 250_000 - ); - - clients.shield_recipient().await; - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_recipient().await; - - assert_eq!( - shielded_pool.spendable_balance(&clients.recipient_balance().await), - 235_000 - ); - - test_manager.close().await; -} - -/// A transparent send returns the same address txids from the non-finalized chain -/// and again after a seam-deep advance lands it in the finalized DB. Shared body of -/// the per-validator gated `send_to_transparent_finalization` tests; the caller -/// launches and funds the faucet first. The advance mines shielded coinbase, so the -/// callers stay `#[ignore]`d until per-call cheap filler mining lands. -pub async fn assert_send_to_transparent_finalization( - mut test_manager: zaino_testutils::TestManager, - mut clients: DevtoolClients, -) where - V: zaino_testutils::ValidatorExt, - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let recipient_taddr = clients.get_recipient_address("transparent").await; - clients.send_from_faucet(&recipient_taddr, 250_000).await; - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - - let oracle = test_manager.full_node_jsonrpc_connector().await; - let height = oracle.get("getblockchaininfo").await["blocks"] - .as_u64() - .expect("a chain height") as u32; - let address_txids = async |address: &str| { - oracle - .call( - "getaddresstxids", - vec![serde_json::json!({ - "addresses": [address], - "start": height, - "end": height, - })], - ) - .await - }; - let unfinalised_transactions = address_txids(&recipient_taddr).await; - - // The load-bearing advance: these blocks push the send below the seam - // (`FAST_TEST_MAX_NONFINALISED_DEPTH`) into the finalized DB. - test_manager - .generate_blocks_bulk_and_wait_for_tips( - // Advance past the seam so the send crosses the finalised floor - // (`tip - seam`); a small margin above it keeps the boundary unambiguous. - zaino_consensus::FAST_TEST_MAX_NONFINALISED_DEPTH + 5, - test_manager.subscriber(), - test_manager.subscriber(), - ) - .await; - - let finalised_transactions = address_txids(&recipient_taddr).await; - - clients.sync_recipient().await; - assert_eq!( - Pool::Transparent.spendable_balance(&clients.recipient_balance().await), - 250_000 - ); - assert_eq!(unfinalised_transactions, finalised_transactions); - - test_manager.close().await; -} diff --git a/live-tests/e2e/src/lib.rs b/live-tests/e2e/src/lib.rs index c2788fd0..0468d1e5 100644 --- a/live-tests/e2e/src/lib.rs +++ b/live-tests/e2e/src/lib.rs @@ -1,16 +1,8 @@ //! Wallet-to-validator integration tests. -//! -//! These exercise the zcash-devtool wallet client (a faucet and a recipient, -//! see [`devtool`]) against a running Zaino indexer, built from a launched -//! [`zaino_testutils::TestManager`]'s gRPC address. The clients are managed by -//! `zcash_local_net`; this workspace keeps its own Cargo.lock for that stack. #![forbid(unsafe_code)] -use zaino_proto::proto::compact_formats::CompactBlock; -use zcash_primitives::transaction::TxId; - -pub mod devtool; +use ztest::prelude::{CompactBlock, TxId}; /// A shielded/transparent pool, paired with the address kind that routes funds /// into it. Lets a send-and-check test take a single `Pool` instead of an @@ -18,8 +10,8 @@ pub mod devtool; #[derive(Clone, Copy, Debug)] pub enum Pool { /// Orchard (funds routed via a unified address through NU6.2; from NU6.3 - /// devtool routes unified-address outputs to Ironwood — use - /// [`Pool::Ironwood`] for the receipt pool on NU6.3-active chains). + /// a unified-address output is routed to Ironwood — use [`Pool::Ironwood`] + /// for the receipt pool on NU6.3-active chains). Orchard, /// Ironwood (funds routed via a unified address from NU6.3). Ironwood, @@ -30,8 +22,7 @@ pub enum Pool { } impl Pool { - /// The `get_recipient_address` / `get_faucet_address` pool name that routes - /// funds into this pool. + /// The pool name that routes funds into this pool. pub fn address_kind(self) -> &'static str { match self { Pool::Orchard | Pool::Ironwood => "unified", @@ -39,6 +30,19 @@ impl Pool { Pool::Transparent => "transparent", } } + + pub fn ztest(self) -> ztest::Pool { + match self { + Pool::Orchard => ztest::Pool::Orchard, + Pool::Ironwood => ztest::Pool::Ironwood, + Pool::Sapling => ztest::Pool::Sapling, + Pool::Transparent => ztest::Pool::Transparent, + } + } + + pub fn spendable_balance(self, balances: &ztest::PoolBalances) -> u64 { + balances.get(self.ztest()) + } } /// Whether the compact tx with `txid` carries no data for `pool` (transparent diff --git a/live-tests/e2e/tests/compact_block_wire.rs b/live-tests/e2e/tests/compact_block_wire.rs index ab77775d..77c1f6be 100644 --- a/live-tests/e2e/tests/compact_block_wire.rs +++ b/live-tests/e2e/tests/compact_block_wire.rs @@ -1,205 +1,167 @@ -//! Wire-tier (zainod gRPC) era tests for compact-block serving. +//! Era composition of the compact blocks a real tonic client receives from a +//! running zainod: the served range is complete and contiguous, and each height's +//! coinbase pays its era's pool (orchard-only, ironwood-only, and the +//! orchard→ironwood flip at the NU6.3 activation height). //! -//! The e2e-exclusive predicate here is **wire fidelity**: the compact blocks a real -//! tonic client receives from a running zainod equal, block for block, what the -//! in-process subscriber produces for the same request. Only this tier crosses the -//! protobuf encode → network → decode boundary and zainod's gRPC server; clientless -//! tests call subscriber methods in-process and can never observe that layer. -//! -//! On top of fidelity, each test asserts the era composition of the served stream for -//! the unfiltered request shape (empty `poolTypes`): orchard-only, ironwood-only, and -//! the orchard→ironwood transition at the NU6.3 activation boundary. - -use zaino_common::network::ActivationHeights; -use zaino_proto::proto::compact_formats::CompactBlock; -use zaino_proto::proto::service::compact_tx_streamer_client::CompactTxStreamerClient; -use zaino_proto::proto::service::{BlockId, BlockRange}; -#[allow(deprecated)] -use zaino_state::ZcashIndexer as _; -use zaino_testutils::{ - make_uri, MinerPool, Rpc, TestManager, ValidatorKind, IRONWOOD_ONLY_ACTIVATION_HEIGHTS, - NU6_3_TRANSITION_BOUNDARY, ORCHARD_ONLY_ACTIVATION_HEIGHTS, - ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS, -}; -use zcash_local_net::validator::zebrad::Zebrad; - -/// Which shielded pool the fixture's coinbase reward must land in at a given height, -/// as observed in the served compact form. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum CoinbaseEra { - /// Below NU5: the shielded-receiver reward cannot land in either pool. - Neither, - /// NU5 through NU6.2: the orchard-receiver reward is paid as Orchard actions. - Orchard, - /// From NU6.3: the same receiver's reward is routed to Ironwood actions. - Ironwood, -} +//! Known failure: served ironwood actions are missing at the first ironwood-era +//! height — . + +use std::time::Duration; + +use anyhow::Result; +use ztest::prelude::*; + +const READY: Duration = Duration::from_secs(120); -/// Launches an orchard-receiver mining fixture with zainod enabled, generates -/// `blocks` blocks, streams `[0, tip]` through zainod's real gRPC wire with the empty -/// `poolTypes` filter, and asserts: -/// -/// 1. wire fidelity — the streamed blocks equal the in-process subscriber's blocks; -/// 2. era composition — each height's served orchard/ironwood action presence matches -/// the era `expected_era` assigns it. -async fn assert_wire_served_eras( - activation_heights: ActivationHeights, - blocks: u32, - expected_era: impl Fn(u64) -> CoinbaseEra, -) { - #[allow(deprecated)] - let mut test_manager = TestManager::::launch_mining_to( - MinerPool::Orchard, - &ValidatorKind::Zebrad, - None, - Some(activation_heights), - None, - true, - false, - false, - ) - .await - .unwrap(); - let subscriber = test_manager.subscriber().clone(); - - test_manager - .generate_blocks_and_wait_for_tip(blocks, &subscriber) - .await; - let tip = u64::from(subscriber.chain_height().await.unwrap().0); - - // The in-process serving result: the same request answered without the wire. - let in_process = zaino_testutils::collect_block_range(&subscriber, 0, tip, vec![]).await; - - // The same request through zainod's real gRPC server. - let uri = make_uri( - test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), +/// The mid-chain NU6.3 (Ironwood) activation height for the transition fixture: +/// an Orchard era `[2, 6)` that flips to Ironwood at height 6. +const NU6_3_TRANSITION_BOUNDARY: u32 = 6; + +/// NU6.3 never activates, so the orchard-receiver coinbase stays in Orchard +/// actions for every block. +#[ztest::qos::integration] +#[tokio::test(flavor = "multi_thread")] +async fn orchard_only_wire_serving_zebrad() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .build(), ); - let mut grpc_client = CompactTxStreamerClient::connect(uri) - .await - .expect("zainod grpc reachable"); - let mut stream = grpc_client - .get_block_range(BlockRange { - start: Some(BlockId { - height: 0, - hash: vec![], - }), - end: Some(BlockId { - height: tip, - hash: vec![], - }), - // The unfiltered wire request real (including pre-Ironwood) clients send. - pool_types: vec![], - }) - .await - .expect("get_block_range succeeds") - .into_inner(); - let mut wire: Vec = Vec::new(); - while let Some(block) = stream.message().await.expect("stream yields blocks") { - wire.push(block); - } + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(Pool::Orchard)); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + + let tip = validator.generate_blocks(6).await?; + indexer.wait_for_block_num(tip, READY).await?; - // E2e-exclusive predicate — wire fidelity: the protobuf encode/decode and zainod's - // gRPC server must be transparent. + let blocks = indexer + .get_block_range(BlockHeight::from(1u32), tip) + .await?; assert_eq!( - wire.len(), - in_process.len(), - "wire and in-process must serve the same block count" + blocks.len() as u64, + u64::from(tip), + "zainod must serve every block in [1, {tip}]" ); - for (wire_block, in_process_block) in wire.iter().zip(&in_process) { - assert_eq!( - wire_block, in_process_block, - "wire round-trip must be transparent at height {}", - wire_block.height - ); - } - // Era composition of the served stream. Observed failing at the first - // ironwood-era height (served orchard 1 / ironwood 0) — hypotheses and the - // raw-block disambiguation procedure are tracked in - // . - for block in &wire { - let orchard_actions: usize = block.vtx.iter().map(|tx| tx.actions.len()).sum(); - let ironwood_actions: usize = block.vtx.iter().map(|tx| tx.ironwood_actions.len()).sum(); - let (want_orchard, want_ironwood) = match expected_era(block.height) { - CoinbaseEra::Neither => (false, false), - CoinbaseEra::Orchard => (true, false), - CoinbaseEra::Ironwood => (false, true), - }; + for (offset, block) in blocks.iter().enumerate() { + let height = 1 + offset as u64; + assert_eq!(block.height, height, "served heights must be contiguous"); + let orchard: usize = block.vtx.iter().map(|tx| tx.actions.len()).sum(); + let ironwood: usize = block.vtx.iter().map(|tx| tx.ironwood_actions.len()).sum(); assert_eq!( - orchard_actions > 0, - want_orchard, - "served orchard actions mismatch at height {} (orchard {orchard_actions}, \ - ironwood {ironwood_actions})", - block.height + orchard > 0, + height >= 2, + "orchard actions at height {height} (orchard {orchard}, ironwood {ironwood})" ); assert_eq!( - ironwood_actions > 0, - want_ironwood, - "served ironwood actions mismatch at height {} (orchard {orchard_actions}, \ - ironwood {ironwood_actions})", - block.height + ironwood, 0, + "no ironwood actions anywhere at height {height} (orchard {orchard})" ); } - - test_manager.close().await; + Ok(()) } -/// Orchard-only era over the wire: NU6.3 never activates (explicit fixture — the -/// zebrad default heights are now the canonical NU6.3-at-2 set), so the served -/// stream carries Orchard actions from height 2 and no ironwood anywhere. -/// -/// multi_thread required: the test manager spawns the validator, indexer, and zainod. +/// The zebrad default heights activate NU6.3 at height 2, so the orchard-receiver +/// coinbase is paid as Ironwood actions from the first shielded block. +#[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] -async fn orchard_only_wire_serving_zebrad() { - assert_wire_served_eras(ORCHARD_ONLY_ACTIVATION_HEIGHTS, 6, |height| { - if height >= 2 { - CoinbaseEra::Orchard - } else { - CoinbaseEra::Neither - } - }) - .await; -} +async fn ironwood_only_wire_serving_zebrad() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(Pool::Orchard)); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; -/// Ironwood-only era over the wire: NU6.3 active from height 2, so the served stream -/// carries Ironwood actions from height 2 and no Orchard actions anywhere. -/// -/// multi_thread required: the test manager spawns the validator, indexer, and zainod. -#[tokio::test(flavor = "multi_thread")] -async fn ironwood_only_wire_serving_zebrad() { - assert_wire_served_eras(IRONWOOD_ONLY_ACTIVATION_HEIGHTS, 6, |height| { - if height >= 2 { - CoinbaseEra::Ironwood - } else { - CoinbaseEra::Neither - } - }) - .await; + let tip = validator.generate_blocks(6).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let blocks = indexer + .get_block_range(BlockHeight::from(1u32), tip) + .await?; + assert_eq!( + blocks.len() as u64, + u64::from(tip), + "zainod must serve every block in [1, {tip}]" + ); + + for (offset, block) in blocks.iter().enumerate() { + let height = 1 + offset as u64; + assert_eq!(block.height, height, "served heights must be contiguous"); + let orchard: usize = block.vtx.iter().map(|tx| tx.actions.len()).sum(); + let ironwood: usize = block.vtx.iter().map(|tx| tx.ironwood_actions.len()).sum(); + assert_eq!( + ironwood > 0, + height >= 2, + "ironwood actions at height {height} (orchard {orchard}, ironwood {ironwood})" + ); + assert_eq!( + orchard, 0, + "no orchard actions anywhere at height {height} (ironwood {ironwood})" + ); + } + Ok(()) } -/// The transition over the wire: the same unchanged orchard-receiver miner's served -/// stream flips from Orchard to Ironwood actions exactly at the NU6.3 activation -/// height. -/// -/// multi_thread required: the test manager spawns the validator, indexer, and zainod. +/// The unchanged orchard-receiver miner's served stream flips from Orchard to +/// Ironwood actions exactly at the NU6.3 activation height. +#[ztest::qos::integration] #[tokio::test(flavor = "multi_thread")] -async fn orchard_to_ironwood_transition_wire_serving_zebrad() { +async fn orchard_to_ironwood_transition_wire_serving_zebrad() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(), + ); + let validator = env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(Pool::Orchard)); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + env.build().await?; + // Two blocks past the boundary, so both eras carry more than one block. - assert_wire_served_eras( - ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS, - NU6_3_TRANSITION_BOUNDARY + 2, - |height| { - if height >= u64::from(NU6_3_TRANSITION_BOUNDARY) { - CoinbaseEra::Ironwood - } else if height >= 2 { - CoinbaseEra::Orchard - } else { - CoinbaseEra::Neither - } - }, - ) - .await; + let mined = NU6_3_TRANSITION_BOUNDARY + 2; + let tip = validator.generate_blocks(mined).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let blocks = indexer + .get_block_range(BlockHeight::from(1u32), tip) + .await?; + assert_eq!( + blocks.len() as u64, + u64::from(tip), + "zainod must serve every block in [1, {tip}]" + ); + + for (offset, block) in blocks.iter().enumerate() { + let height = 1 + offset as u64; + assert_eq!(block.height, height, "served heights must be contiguous"); + let orchard: usize = block.vtx.iter().map(|tx| tx.actions.len()).sum(); + let ironwood: usize = block.vtx.iter().map(|tx| tx.ironwood_actions.len()).sum(); + let boundary = u64::from(NU6_3_TRANSITION_BOUNDARY); + assert_eq!( + orchard > 0, + (2..boundary).contains(&height), + "orchard actions at height {height} (orchard {orchard}, ironwood {ironwood})" + ); + assert_eq!( + ironwood > 0, + height >= boundary, + "ironwood actions at height {height} (orchard {orchard}, ironwood {ironwood})" + ); + } + Ok(()) } diff --git a/live-tests/e2e/tests/devtool.rs b/live-tests/e2e/tests/devtool.rs deleted file mode 100644 index 218bd7d2..00000000 --- a/live-tests/e2e/tests/devtool.rs +++ /dev/null @@ -1,2476 +0,0 @@ -//! Devtool-backed ports of the wallet-to-validator tests, run against a Zaino -//! launched by `TestManager`. As the zcash-devtool client -//! ([`e2e::devtool`]) reaches capability parity with the zingolib -//! `Clients`, the matching tests in `wallet_to_validator.rs` migrate here and -//! the zingolib versions are eventually retired (zingolabs/infrastructure#269). -//! -//! Zebrad only: the zcashd matrix is deferred (see below). -//! -//! Covered (the full zebrad fetch + state query/send surface): -//! - sends to each pool, `send_to_all`, shielding, mining-reward receipt; -//! - `get_transaction` (mined / mempool), `get_raw_transaction`; -//! - mempool: `get_raw_mempool`, `get_mempool_tx`, `get_mempool_stream`; -//! - address queries: `get_address_tx_ids`, `get_address_utxos`, -//! `get_address_balance`, the `get_taddress_*` family (recipient and -//! faucet-coinbase variants), `get_address_transactions_regtest`; -//! - tree state: `z_get_treestate`, `z_get_subtrees_by_index`; -//! - block range: default/all pools and the out-of-range edge cases; -//! - compact-block transparent data; -//! - `connect_to_node_get_info` (wallet `get_info` smoke). -//! Dual `*_fetch_vs_state` tests assert the fetch and state backends agree. -//! -//! Deferred, with the capability each waits on: -//! - `send_to_transparent` (heavy / finalization) — runnable now via orchard -//! funding, but the load-bearing seam-deep advance across the finalized / -//! non-finalized boundary costs a halo2 proof per block; waits on cheap -//! transparent filler-block mining. -//! - `monitor_unverified_mempool` — unconfirmed (mempool) wallet balances; -//! devtool sync is block-based (likely stays on zingolib, the indexer-side -//! mempool views above already cover the surface). -//! - the zcashd matrix (`json_server`, zcashd send/query) — the devtool wallet -//! rejects zcashd's default regtest activation heights at construction; -//! `json_server` is additionally zcashd-bound (its reference subscriber *is* -//! zcashd). -//! - the `test_vectors` chain builder — devtool transparent-coinbase shielding. -//! - `get_mempool_info` — recomputes expected sizes from -//! `FetchServiceSubscriber` internals; low value over the mempool surfaces -//! already covered. -//! -//! Requires a `zcash-devtool` binary built with `--features regtest_support` -//! in `TEST_BINARIES_DIR`/`PATH`, alongside the usual validator binaries. - -use e2e::devtool::DevtoolClients; -use zaino_proto::proto::service::{ - AddressList, BlockId, BlockRange, GetAddressUtxosArg, TransparentAddressBlockFilter, TxFilter, -}; -use zaino_state::{LightWalletIndexer, ZcashIndexer}; -use zaino_testutils::{PollableTip, Rpc, TestManager, ValidatorKind}; -use zcash_local_net::validator::zebrad::Zebrad; -use zebra_chain::subtree::NoteCommitmentSubtreeIndex; -use zebra_rpc::methods::{GetAddressBalanceRequest, GetAddressTxIdsRequest}; - -/// Launch an orchard-mining zebrad + Zaino on the `Service` backend, build -/// devtool faucet/recipient wallets against it, mine `coinbase_blocks` blocks -/// past the launch, and sync the faucet. Each mined block past the launch is -/// an orchard coinbase note for the faucet (height 1 is the -/// sapling-activation coinbase), so `coinbase_blocks` is the number of -/// spendable orchard notes the faucet ends with — one per send a test makes -/// before mining (devtool will not chain unconfirmed change). The shared -/// launch+fund preamble of the ports below. -async fn launch_and_fund_faucet( - coinbase_blocks: u32, -) -> (TestManager, DevtoolClients) -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let test_manager = TestManager::::launch_mining_to( - zaino_testutils::SHIELDED_FUNDING_POOL, - &ValidatorKind::Zebrad, - None, - None, - None, - true, - false, - false, - ) - .await - .expect("launch TestManager"); - - let mut clients = e2e::devtool::build_clients( - test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &test_manager.local_net, - ) - .await; - - test_manager - .generate_blocks_and_wait_for_tip(coinbase_blocks, test_manager.subscriber()) - .await; - clients.sync_faucet().await; - - (test_manager, clients) -} - -/// Launch an orchard-mining zebrad + Zaino on the `Service` backend and build -/// devtool faucet/recipient wallets against it, without mining or syncing — the -/// minimal preamble for tests that only exercise wallet↔server connectivity. -async fn launch_and_build_clients() -> (TestManager, DevtoolClients) -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - e2e::devtool::launch_and_build_devtool_clients(&ValidatorKind::Zebrad, None).await -} - -/// Port of `connect_to_node_get_info` (wallet_to_validator, zebrad): the faucet -/// and recipient wallets can report node/server info without erroring. Smoke -/// test — the original discards the result. (`chain_name` is "test" for regtest -/// and `server_uri` has no trailing slash, so this asserts neither.) -async fn connect_to_node_get_info() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, clients) = launch_and_build_clients::().await; - - clients.get_info_faucet().await; - clients.get_info_recipient().await; - - test_manager.close().await; -} - -/// Port of `wallet_to_validator::check_received_mining_reward` (zebrad): the -/// faucet's synced wallet sees the orchard coinbase note. -async fn receives_mining_reward() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, clients) = launch_and_fund_faucet::(1).await; - - let faucet_balance = dbg!(clients.faucet_balance().await); - // Under the canonical NU6.3-at-2 heights the orchard-receiver mining reward - // is routed to the Ironwood pool. - assert!( - faucet_balance.ironwood_spendable > 0, - "faucet should hold a spendable ironwood coinbase note, got {faucet_balance:?}" - ); - - test_manager.close().await; -} - -/// Port of the `assert_send_to_pool` family from `wallet_to_validator` -/// (zebrad): send 250_000 from the faucet (spending its orchard coinbase) to -/// the recipient's `pool` address, mine it in, and assert the recipient's -/// synced wallet shows the receipt in that pool. Covers `send_to_ironwood` -/// (unified, the NU6.3-era receipt pool), `send_to_sapling`, and the basic transparent receipt — the -/// per-pool address wiring is the new surface under test. (The original -/// `send_to_transparent` additionally mines across the finalization boundary -/// under transparent mining; that variant is deferred, as it exercises the -/// transparent-coinbase detection path devtool has not yet been run against.) -async fn send_to_pool(pool: e2e::Pool) -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (test_manager, clients) = launch_and_fund_faucet::(1).await; - e2e::devtool::assert_send_to_pool(test_manager, clients, pool).await; -} - -/// Port of `send_to_all` (wallet_to_validator, zebrad): one faucet funds a send -/// to all three pools at once; each recipient pool reports 250_000. -/// -/// The original mined to a transparent miner and ran a 100-block "heavy mine" -/// after the sends; here the faucet is funded by orchard coinbase notes (the -/// mine-to-orchard funding pattern, no shield) and the sends are confirmed with -/// a single block. The 100-block mine is not load-bearing for the per-pool -/// balance assertions — the sends confirm in one block, and received funds are -/// regular (non-coinbase) outputs with no maturity rule — and 100 orchard -/// blocks would cost 100 halo2 proofs against the net-speedup criterion. -async fn send_to_all() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - // Three orchard notes — one per send (devtool will not chain unconfirmed change). - let (mut test_manager, mut clients) = launch_and_fund_faucet::(3).await; - - let recipient_ua = clients.get_recipient_address("unified").await; - let recipient_zaddr = clients.get_recipient_address("sapling").await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - clients.send_from_faucet(&recipient_ua, 250_000).await; - clients.send_from_faucet(&recipient_zaddr, 250_000).await; - clients.send_from_faucet(&recipient_taddr, 250_000).await; - - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_recipient().await; - - let balance = clients.recipient_balance().await; - // From NU6.3 devtool routes unified-address outputs to Ironwood; the - // orchard pool must stay empty (a nonzero orchard here means the receipt - // was mislabelled, not merely misrouted). - assert_eq!(e2e::Pool::Ironwood.spendable_balance(&balance), 250_000); - assert_eq!(e2e::Pool::Orchard.spendable_balance(&balance), 0); - assert_eq!(e2e::Pool::Sapling.spendable_balance(&balance), 250_000); - assert_eq!(e2e::Pool::Transparent.spendable_balance(&balance), 250_000); - - test_manager.close().await; -} - -/// Port of `wallet_to_validator::shield_for_validator` (zebrad): the faucet -/// sends 250_000 to the recipient's transparent address; the recipient -/// confirms the transparent receipt, shields it (to Ironwood from NU6.3), and -/// confirms the shielded balance net of the ZIP-317 fee (250_000 − 15_000 = -/// 235_000 — the first devtool exercise of `shield`, and the constant flagged -/// as needing re-verification against devtool's note selection). -async fn shield_for_validator() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (test_manager, clients) = launch_and_fund_faucet::(1).await; - e2e::devtool::assert_shield_for_validator(test_manager, clients, e2e::Pool::Ironwood).await; -} - -/// Launch, fund the faucet with two orchard notes, and broadcast (without -/// mining) one transparent and one unified send into the mempool. Returns the -/// manager and the two broadcast txids (transparent, then unified). The -/// devtool analogue of `fund_and_fill_mempool`. -async fn fund_and_fill_mempool() -> (TestManager, String, String) -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - // Two orchard notes — one per unmined send. - let (test_manager, mut clients) = launch_and_fund_faucet::(2).await; - - let recipient_taddr = clients.get_recipient_address("transparent").await; - let recipient_ua = clients.get_recipient_address("unified").await; - let transparent_txid = clients.send_from_faucet(&recipient_taddr, 250_000).await; - let unified_txid = clients.send_from_faucet(&recipient_ua, 250_000).await; - - // Allow the broadcaster and the indexer to observe the unmined transactions. - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - (test_manager, transparent_txid, unified_txid) -} - -/// Fund the faucet, send 250_000 to the recipient's `pool` address, mine it -/// in, and return the manager, clients (for address lookup), and the broadcast -/// txid hex. The txid is in display (reversed) order — devtool prints it that -/// way, which matches the txid strings zaino's address queries return, so no -/// conversion is needed for those comparisons. -async fn fund_and_send_to( - pool: e2e::Pool, -) -> (TestManager, DevtoolClients, String) -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (test_manager, mut clients) = launch_and_fund_faucet::(1).await; - - let recipient = clients.get_recipient_address(pool.address_kind()).await; - let txid_hex = clients.send_from_faucet(&recipient, 250_000).await; - - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - - (test_manager, clients, txid_hex) -} - -/// Port of `fetch_service_get_address_tx_ids` (zebrad): after a transparent -/// send to the recipient, `get_address_tx_ids` over the recipient's taddr -/// returns the send's txid. -async fn get_address_tx_ids() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, clients, txid_hex) = - fund_and_send_to::(e2e::Pool::Transparent).await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - - // A range start a couple of blocks below the tip, covering the send block. - let start = test_manager.subscriber().tip_height().await as u32 - 2; - let txids = test_manager - .subscriber() - .get_address_tx_ids(GetAddressTxIdsRequest::new( - vec![recipient_taddr], - Some(start), - None, - )) - .await - .unwrap(); - - dbg!(&txid_hex, &txids); - assert_eq!(txid_hex.trim(), txids[0]); - - test_manager.close().await; -} - -/// Port of `fetch_service_get_address_utxos` (zebrad): after a transparent -/// send to the recipient, `z_get_address_utxos` over the recipient's taddr -/// returns a utxo whose txid is the send's. -async fn get_address_utxos() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, clients, txid_hex) = - fund_and_send_to::(e2e::Pool::Transparent).await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - - let utxos = test_manager - .subscriber() - .z_get_address_utxos(GetAddressBalanceRequest::new(vec![recipient_taddr])) - .await - .unwrap(); - let utxo_txid = utxos[0].txid; - - dbg!(&txid_hex, &utxo_txid); - assert_eq!(txid_hex.trim(), utxo_txid.to_string()); - - test_manager.close().await; -} - -/// Port of `fetch_service_z_get_treestate` (zebrad, smoke): `z_get_treestate` -/// at the tip height succeeds. -async fn z_get_treestate() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, _clients, _txid_hex) = - fund_and_send_to::(e2e::Pool::Orchard).await; - - let tip = test_manager.subscriber().tip_height().await; - dbg!(test_manager - .subscriber() - .z_get_treestate(tip.to_string()) - .await - .unwrap()); - - test_manager.close().await; -} - -/// Port of `fetch_service_z_get_subtrees_by_index` (zebrad, smoke): -/// `z_get_subtrees_by_index` for orchard succeeds. -async fn z_get_subtrees_by_index() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, _clients, _txid_hex) = - fund_and_send_to::(e2e::Pool::Orchard).await; - - dbg!(test_manager - .subscriber() - .z_get_subtrees_by_index( - zaino_primitives::types::ShieldedPool::Orchard, - NoteCommitmentSubtreeIndex(0), - None - ) - .await - .unwrap()); - - test_manager.close().await; -} - -/// Port of `fetch_service_get_raw_transaction` (zebrad, smoke): -/// `get_raw_transaction` for the orchard send's txid succeeds. -async fn get_raw_transaction() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, _clients, txid_hex) = fund_and_send_to::(e2e::Pool::Orchard).await; - - dbg!(test_manager - .subscriber() - .get_raw_transaction(txid_hex.trim().to_string(), Some(1)) - .await - .unwrap()); - - test_manager.close().await; -} - -/// Port of `fetch_service_get_taddress_txids` (zebrad, smoke): -/// `get_taddress_txids` over the recipient's taddr and a height range around -/// the send succeeds. -async fn get_taddress_txids() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - use futures::StreamExt as _; - - let (mut test_manager, clients, _txid_hex) = - fund_and_send_to::(e2e::Pool::Transparent).await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - - let tip = test_manager.subscriber().tip_height().await; - let block_filter = TransparentAddressBlockFilter { - address: recipient_taddr, - range: Some(BlockRange { - start: Some(BlockId { - height: tip - 2, - hash: Vec::new(), - }), - end: Some(BlockId { - height: tip, - hash: Vec::new(), - }), - pool_types: zaino_testutils::all_pools_i32(), - }), - }; - - let stream_items: Vec<_> = test_manager - .subscriber() - .get_taddress_txids(block_filter) - .await - .unwrap() - .collect() - .await; - let txids: Vec<_> = stream_items.into_iter().filter_map(|r| r.ok()).collect(); - dbg!(&txids); - - test_manager.close().await; -} - -/// Port of `fetch_service_get_taddress_utxos` (zebrad, smoke): -/// `get_address_utxos` over the recipient's taddr succeeds. -async fn get_taddress_utxos() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, clients, _txid_hex) = - fund_and_send_to::(e2e::Pool::Transparent).await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - - let utxos_arg = GetAddressUtxosArg { - addresses: vec![recipient_taddr], - start_height: 0, - max_entries: 0, - }; - dbg!(test_manager - .subscriber() - .get_address_utxos(utxos_arg) - .await - .unwrap()); - - test_manager.close().await; -} - -/// Port of `fetch_service_get_taddress_utxos_stream` (zebrad, smoke): -/// `get_address_utxos_stream` over the recipient's taddr succeeds. -async fn get_taddress_utxos_stream() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - use futures::StreamExt as _; - - let (mut test_manager, clients, _txid_hex) = - fund_and_send_to::(e2e::Pool::Transparent).await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - - let utxos_arg = GetAddressUtxosArg { - addresses: vec![recipient_taddr], - start_height: 0, - max_entries: 0, - }; - let stream_items: Vec<_> = test_manager - .subscriber() - .get_address_utxos_stream(utxos_arg) - .await - .unwrap() - .collect() - .await; - let utxos: Vec<_> = stream_items.into_iter().filter_map(|r| r.ok()).collect(); - dbg!(utxos); - - test_manager.close().await; -} - -/// Port of `fetch_service_get_raw_mempool` (zebrad): with two transactions -/// broadcast but unmined, the indexer's `get_raw_mempool` matches the -/// validator's own JSON-RPC `getrawmempool`. -async fn get_raw_mempool() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, _transparent_txid, _unified_txid) = - fund_and_fill_mempool::().await; - - let json_service = test_manager.full_node_jsonrpc_connector().await; - - let mut zaino_mempool = test_manager.subscriber().get_raw_mempool().await.unwrap(); - let mut validator_mempool: Vec = - serde_json::from_value(json_service.get("getrawmempool").await) - .expect("getrawmempool returns an array of txids"); - - dbg!(&zaino_mempool); - dbg!(&validator_mempool); - zaino_mempool.sort(); - validator_mempool.sort(); - assert_eq!(validator_mempool, zaino_mempool); - - test_manager.close().await; -} - -/// Port of `fetch_service_get_mempool_tx` (zebrad): the `get_mempool_tx` -/// stream returns the two unmined transactions keyed by txid (internal byte -/// order), and the exclude-by-txid-suffix filter drops the named one. -async fn get_mempool_tx() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - use futures::StreamExt as _; - use zaino_proto::proto::service::GetMempoolTxRequest; - - let (mut test_manager, transparent_txid, unified_txid) = fund_and_fill_mempool::().await; - - let to_bytes = |hex: &str| -> [u8; 32] { - e2e::devtool::txid_internal_bytes(hex) - .try_into() - .expect("txid is 32 bytes") - }; - let mut sorted_txids = [to_bytes(&transparent_txid), to_bytes(&unified_txid)]; - sorted_txids.sort(); - - let subscriber = test_manager.subscriber().clone(); - let collect = |req: GetMempoolTxRequest| { - let subscriber = subscriber.clone(); - async move { - let stream_items: Vec<_> = subscriber - .get_mempool_tx(req) - .await - .unwrap() - .collect() - .await; - let mut txs: Vec<_> = stream_items.into_iter().filter_map(|r| r.ok()).collect(); - txs.sort_by_key(|tx| tx.txid.clone()); - txs - } - }; - - // Both transactions present, no exclusions. - let all = collect(GetMempoolTxRequest { - exclude_txid_suffixes: Vec::new(), - pool_types: Vec::new(), - }) - .await; - assert_eq!(all.len(), 2); - assert_eq!(all[0].txid, sorted_txids[0]); - assert_eq!(all[1].txid, sorted_txids[1]); - - // Excluding the first by its txid suffix leaves only the second. - let remaining = collect(GetMempoolTxRequest { - exclude_txid_suffixes: vec![sorted_txids[0][8..].to_vec()], - pool_types: Vec::new(), - }) - .await; - assert_eq!(remaining.len(), 1); - assert_eq!(remaining[0].txid, sorted_txids[1]); - - test_manager.close().await; -} - -/// Port of `fetch_service_get_mempool_stream` (zebrad): a `get_mempool_stream` -/// subscription opened before two sends observes those unmined transactions. -/// Smoke test — the original only `dbg!`s the collected stream. -async fn get_mempool_stream() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - use futures::StreamExt as _; - - // Two orchard notes — one per unmined send. - let (mut test_manager, mut clients) = launch_and_fund_faucet::(2).await; - - // Subscribe before the sends so the stream observes them entering the mempool. - let subscriber = test_manager.subscriber().clone(); - let stream_handle = tokio::spawn(async move { - let stream = subscriber.get_mempool_stream().await.unwrap(); - let items: Vec<_> = stream.collect().await; - items - .into_iter() - .filter_map(|result| result.ok()) - .collect::>() - }); - - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - let recipient_ua = clients.get_recipient_address("unified").await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - clients.send_from_faucet(&recipient_taddr, 250_000).await; - clients.send_from_faucet(&recipient_ua, 250_000).await; - - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - - let mempool_tx = stream_handle.await.unwrap(); - - let mut sorted_mempool_tx = mempool_tx.clone(); - sorted_mempool_tx.sort_by_key(|tx| tx.data.clone()); - - dbg!(sorted_mempool_tx); - - test_manager.close().await; -} - -/// Fund the faucet, send 250_000 to the recipient's unified address, and mine -/// it in. Returns the manager and the broadcast txid in zaino's internal byte -/// order. The devtool analogue of `fund_and_send(Pool::Orchard)`; the wallet -/// clients are dropped once the transaction is mined (the queries below hit -/// zaino, not the wallet). -async fn fund_and_send_orchard() -> (TestManager, Vec) -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (test_manager, mut clients) = launch_and_fund_faucet::(1).await; - - let recipient_ua = clients.get_recipient_address("unified").await; - let txid_hex = clients.send_from_faucet(&recipient_ua, 250_000).await; - - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - - (test_manager, e2e::devtool::txid_internal_bytes(&txid_hex)) -} - -/// Port of `fetch_service_get_transaction_mined` (zebrad): the indexer serves -/// `get_transaction` for the mined orchard send, keyed by its txid. Also -/// confirms `txid_internal_bytes` yields the order the indexer matches on. -async fn get_transaction_mined() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, txid_bytes) = fund_and_send_orchard::().await; - - let tx_filter = TxFilter { - block: None, - index: 0, - hash: txid_bytes, - }; - let raw_transaction = test_manager - .subscriber() - .get_transaction(tx_filter) - .await - .unwrap(); - dbg!(raw_transaction); - - test_manager.close().await; -} - -/// Port of `fetch_service_get_transaction_mempool` (zebrad): the indexer -/// serves `get_transaction` for an orchard send that is broadcast but not -/// mined, keyed by its txid — i.e. from the mempool. -/// -/// Also the end-to-end guard for the unconfirmed-height wire contract: an -/// unmined transaction must report `height = 0` (lightwalletd's "in the mempool" -/// sentinel), never the chain-tip height, which a client would misread as a -/// confirmation. That sentinel is chosen deep in the serving path, so only a -/// real broadcast-but-unmined transaction exercises it. -async fn get_transaction_mempool() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, mut clients) = launch_and_fund_faucet::(1).await; - - let recipient_ua = clients.get_recipient_address("unified").await; - let txid_hex = clients.send_from_faucet(&recipient_ua, 250_000).await; - let expected_txid_bytes = e2e::devtool::txid_internal_bytes(&txid_hex); - let tx_filter = TxFilter { - block: None, - index: 0, - hash: expected_txid_bytes.clone(), - }; - - use zebra_chain::serialization::ZcashDeserializeInto as _; - - // Let the broadcaster and the indexer observe the unmined transaction. - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - let raw_transaction = test_manager - .subscriber() - .get_transaction(tx_filter) - .await - .unwrap(); - - assert_eq!( - raw_transaction.height, 0, - "an unmined transaction must carry the mempool height sentinel, not the tip height" - ); - - // The bytes served are the transaction that was actually sent. - let served: zebra_chain::transaction::Transaction = raw_transaction - .data - .as_ref() - .zcash_deserialize_into() - .expect("served mempool transaction must deserialize"); - assert_eq!( - served.hash().0.to_vec(), - expected_txid_bytes, - "get_transaction served a different transaction than the one broadcast" - ); - - // Mine it, and the same query must flip from the mempool sentinel to a real - // confirmation height — the mempool-to-mined transition, end to end. - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - - let mined = test_manager - .subscriber() - .get_transaction(TxFilter { - block: None, - index: 0, - hash: expected_txid_bytes.clone(), - }) - .await - .unwrap(); - assert!( - mined.height > 0, - "a mined transaction must report its confirmation height, not the mempool sentinel" - ); - - test_manager.close().await; -} - -/// Port of `state_service_get_block_range_returns_default_pools` (zebrad): -/// fund the faucet, send 250_000 to the recipient's unified address, mine it -/// in, then assert that `get_block_range` with no pools requested returns the -/// same shielded compact blocks as requesting sapling+orchard, that the -/// fetch- and state-service indexers agree, and that the tip block holds the -/// shielded coinbase and the send with no transparent data. -async fn block_range_returns_default_pools() { - let mut svc = zaino_testutils::launch_state_and_fetch_services_mining_to::( - zaino_testutils::SHIELDED_FUNDING_POOL, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - - let mut clients = e2e::devtool::build_clients( - svc.test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &svc.test_manager.local_net, - ) - .await; - - // fund_and_send(Orchard): one orchard coinbase note, then send it to the - // recipient's unified address and mine the send in. - svc.generate_blocks_and_wait_for_tips(1).await; - clients.sync_faucet().await; - let recipient_ua = clients.get_recipient_address("unified").await; - clients.send_from_faucet(&recipient_ua, 250_000).await; - svc.generate_blocks_and_wait_for_tips(1).await; - - let start_height: u64 = 1; - let end_height: u64 = svc.fetch_subscriber.tip_height().await; - - let fetch_default = zaino_testutils::collect_block_range( - &svc.fetch_subscriber, - start_height, - end_height, - vec![], - ) - .await; - let fetch_shielded = zaino_testutils::collect_block_range( - &svc.fetch_subscriber, - start_height, - end_height, - zaino_testutils::shielded_pools_i32(), - ) - .await; - assert_eq!(fetch_default, fetch_shielded); - - let state_shielded = zaino_testutils::collect_block_range( - &svc.state_subscriber, - start_height, - end_height, - zaino_testutils::shielded_pools_i32(), - ) - .await; - let state_default = zaino_testutils::collect_block_range( - &svc.state_subscriber, - start_height, - end_height, - vec![], - ) - .await; - assert_eq!(state_default, state_shielded); - - assert_eq!(fetch_default, state_default); - - let compact_block = state_default.last().unwrap(); - assert_eq!(compact_block.height, end_height); - // The tip block holds the shielded coinbase (the miner address is a - // shielded pool) and the send. - assert_eq!(compact_block.vtx.len(), 2); - assert_eq!(compact_block.vtx.last().unwrap().index, 1); - for tx in &compact_block.vtx { - assert_eq!( - tx.vin, - vec![], - "transparent data should not be present when no pool types are specified in the request." - ); - assert_eq!( - tx.vout, - vec![], - "transparent data should not be present when no pool types are specified in the request." - ); - } - - svc.test_manager.close().await; -} - -/// Port of `state_service_get_block_range_returns_all_pools` (zebrad): fund -/// the faucet with three orchard coinbase notes, send 250_000 to the -/// recipient's transparent, sapling, and unified addresses (one tx each), -/// mine them into one block, then assert `get_block_range` with all pools -/// requested agrees between the fetch and state indexers and that the tip -/// block carries the coinbase plus all three sends with their pool data. -async fn block_range_returns_all_pools() { - let mut svc = zaino_testutils::launch_state_and_fetch_services_mining_to::( - zaino_testutils::SHIELDED_FUNDING_POOL, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - - let mut clients = e2e::devtool::build_clients( - svc.test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &svc.test_manager.local_net, - ) - .await; - - // Three ironwood coinbase notes (one per send below — devtool will not - // chain unconfirmed change), then one send to each pool's recipient - // address, mined into a single block. - svc.generate_blocks_and_wait_for_tips(3).await; - clients.sync_faucet().await; - - let recipient_t = clients.get_recipient_address("transparent").await; - let recipient_s = clients.get_recipient_address("sapling").await; - let recipient_u = clients.get_recipient_address("unified").await; - let deshielding_txid = clients.send_from_faucet(&recipient_t, 250_000).await; - let sapling_txid = clients.send_from_faucet(&recipient_s, 250_000).await; - let ironwood_txid = clients.send_from_faucet(&recipient_u, 250_000).await; - svc.generate_blocks_and_wait_for_tips(1).await; - - let start_height: u64 = 1; - let end_height: u64 = svc.fetch_subscriber.tip_height().await; - let all_pools = zaino_testutils::all_pools_i32(); - - let fetch_range = zaino_testutils::collect_block_range( - &svc.fetch_subscriber, - start_height, - end_height, - all_pools.clone(), - ) - .await; - let state_range = zaino_testutils::collect_block_range( - &svc.state_subscriber, - start_height, - end_height, - all_pools, - ) - .await; - assert_eq!(fetch_range, state_range); - - let compact_block = state_range.last().unwrap(); - assert_eq!(compact_block.height, end_height); - // coinbase + the three sends - assert_eq!(compact_block.vtx.len(), 4); - - e2e::assert_pool_present( - compact_block, - &e2e::devtool::txid_from_devtool(&deshielding_txid), - e2e::Pool::Transparent, - ); - e2e::assert_pool_present( - compact_block, - &e2e::devtool::txid_from_devtool(&sapling_txid), - e2e::Pool::Sapling, - ); - let ironwood_txid = e2e::devtool::txid_from_devtool(&ironwood_txid); - e2e::assert_pool_present(compact_block, &ironwood_txid, e2e::Pool::Ironwood); - // The unified-address send must carry no Orchard actions from NU6.3. - e2e::assert_pool_absent(compact_block, &ironwood_txid, e2e::Pool::Orchard); - - svc.test_manager.close().await; -} - -/// Launch dual fetch+state services, fund the faucet, send 250_000 to the -/// recipient's `pool` address, and mine it in. Returns the services, the -/// broadcast txid hex (display order), and the recipient address. The devtool -/// analogue of the state_service `fund_and_send` (dual-subscriber); the wallet -/// clients are dropped once the send is mined (the queries below hit zaino). -/// Port of `fetch_service_get_address_balance` (zebrad): after a transparent -/// send of 250_000 to the recipient, `z_get_address_balance` over that taddr -/// reports exactly 250_000. -async fn get_address_balance() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, clients, _txid_hex) = - fund_and_send_to::(e2e::Pool::Transparent).await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - - let balance = test_manager - .subscriber() - .z_get_address_balance(GetAddressBalanceRequest::new(vec![recipient_taddr])) - .await - .unwrap(); - - dbg!(&balance); - // The fixture sent exactly 250_000 to the recipient taddr. - assert_eq!(u64::from(balance.balance), 250_000); - - test_manager.close().await; -} - -/// Port of `fetch_service_get_taddress_balance` (zebrad): after a transparent -/// send of 250_000 to the recipient, `get_taddress_balance` over that taddr -/// reports value_zat 250_000. -async fn get_taddress_balance() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, clients, _txid_hex) = - fund_and_send_to::(e2e::Pool::Transparent).await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - - let address_list = AddressList { - addresses: vec![recipient_taddr], - }; - let balance = test_manager - .subscriber() - .get_taddress_balance(address_list) - .await - .unwrap(); - - dbg!(&balance); - // The fixture sent exactly 250_000 to the recipient taddr. - assert_eq!(balance.value_zat, 250_000); - - test_manager.close().await; -} - -async fn fund_and_send_dual( - pool: e2e::Pool, -) -> ( - zaino_testutils::StateAndFetchServices, - String, - String, -) { - let svc = zaino_testutils::launch_state_and_fetch_services_mining_to::( - zaino_testutils::SHIELDED_FUNDING_POOL, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - - let mut clients = e2e::devtool::build_clients( - svc.test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &svc.test_manager.local_net, - ) - .await; - - svc.generate_blocks_and_wait_for_tips(1).await; - clients.sync_faucet().await; - let recipient = clients.get_recipient_address(pool.address_kind()).await; - let txid_hex = clients.send_from_faucet(&recipient, 250_000).await; - svc.generate_blocks_and_wait_for_tips(1).await; - - (svc, txid_hex, recipient) -} - -/// Port of `state_service_z_get_treestate` (zebrad): the fetch and state -/// indexers agree on `z_get_treestate` at the tip. -async fn z_get_treestate_fetch_vs_state() { - let (mut svc, _txid_hex, _addr) = fund_and_send_dual(e2e::Pool::Orchard).await; - - let tip = svc.fetch_subscriber.tip_height().await; - let fetch = svc - .fetch_subscriber - .z_get_treestate(tip.to_string()) - .await - .unwrap(); - let state = svc - .state_subscriber - .z_get_treestate(tip.to_string()) - .await - .unwrap(); - assert_eq!(fetch, state); - - svc.test_manager.close().await; -} - -/// Port of `state_service_z_get_subtrees_by_index` (zebrad): the fetch and -/// state indexers agree on `z_get_subtrees_by_index` for orchard. -async fn z_get_subtrees_by_index_fetch_vs_state() { - let (mut svc, _txid_hex, _addr) = fund_and_send_dual(e2e::Pool::Orchard).await; - - let fetch = svc - .fetch_subscriber - .z_get_subtrees_by_index( - zaino_primitives::types::ShieldedPool::Orchard, - NoteCommitmentSubtreeIndex(0), - None, - ) - .await - .unwrap(); - let state = svc - .state_subscriber - .z_get_subtrees_by_index( - zaino_primitives::types::ShieldedPool::Orchard, - NoteCommitmentSubtreeIndex(0), - None, - ) - .await - .unwrap(); - assert_eq!(fetch, state); - - svc.test_manager.close().await; -} - -/// Port of `state_service_get_raw_transaction` (zebrad): the fetch and state -/// indexers agree on `get_raw_transaction` for the orchard send's txid. -async fn get_raw_transaction_fetch_vs_state() { - let (mut svc, txid_hex, _addr) = fund_and_send_dual(e2e::Pool::Orchard).await; - let txid = txid_hex.trim().to_string(); - - let fetch = svc - .fetch_subscriber - .get_raw_transaction(txid.clone(), Some(1)) - .await - .unwrap(); - let state = svc - .state_subscriber - .get_raw_transaction(txid, Some(1)) - .await - .unwrap(); - assert_eq!(fetch, state); - - svc.test_manager.close().await; -} - -/// Port of `state_service_get_address_tx_ids` (zebrad): `get_address_tx_ids` -/// over the recipient's taddr returns the send's txid, and the fetch and state -/// indexers agree. -async fn get_address_tx_ids_fetch_vs_state() { - let (mut svc, txid_hex, recipient_taddr) = fund_and_send_dual(e2e::Pool::Transparent).await; - - let tip = svc.fetch_subscriber.tip_height().await; - let start = Some((tip - 2) as u32); - let end = Some(tip as u32); - let fetch = svc - .fetch_subscriber - .get_address_tx_ids(GetAddressTxIdsRequest::new( - vec![recipient_taddr.clone()], - start, - end, - )) - .await - .unwrap(); - let state = svc - .state_subscriber - .get_address_tx_ids(GetAddressTxIdsRequest::new( - vec![recipient_taddr], - start, - end, - )) - .await - .unwrap(); - assert_eq!(txid_hex.trim(), fetch[0]); - assert_eq!(fetch, state); - - svc.test_manager.close().await; -} - -/// Port of `state_service_get_address_utxos` (zebrad): `z_get_address_utxos` -/// over the recipient's taddr returns the send's txid, and the fetch and state -/// indexers agree on it. -async fn get_address_utxos_fetch_vs_state() { - let (mut svc, txid_hex, recipient_taddr) = fund_and_send_dual(e2e::Pool::Transparent).await; - - let fetch_utxos = svc - .fetch_subscriber - .z_get_address_utxos(GetAddressBalanceRequest::new(vec![recipient_taddr.clone()])) - .await - .unwrap(); - let fetch_txid = fetch_utxos[0].txid; - let state_utxos = svc - .state_subscriber - .z_get_address_utxos(GetAddressBalanceRequest::new(vec![recipient_taddr])) - .await - .unwrap(); - let state_txid = state_utxos[0].txid; - - assert_eq!(txid_hex.trim(), fetch_txid.to_string()); - assert_eq!(fetch_txid.to_string(), state_txid.to_string()); - - svc.test_manager.close().await; -} - -/// Dual-backend analogue of [`fund_and_fill_mempool`]: launch state+fetch -/// services, fund the faucet with two orchard notes, then broadcast a -/// transparent and a unified send (unmined) so both indexers' mempools hold -/// them. Returns the services. -async fn fund_and_fill_mempool_dual() -> zaino_testutils::StateAndFetchServices { - let svc = zaino_testutils::launch_state_and_fetch_services_mining_to::( - zaino_testutils::SHIELDED_FUNDING_POOL, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - - let mut clients = e2e::devtool::build_clients( - svc.test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &svc.test_manager.local_net, - ) - .await; - - // Two orchard notes — one per unmined send. - svc.generate_blocks_and_wait_for_tips(2).await; - clients.sync_faucet().await; - - let recipient_taddr = clients.get_recipient_address("transparent").await; - let recipient_ua = clients.get_recipient_address("unified").await; - clients.send_from_faucet(&recipient_taddr, 250_000).await; - clients.send_from_faucet(&recipient_ua, 250_000).await; - - // Allow the broadcaster and the indexers to observe the unmined transactions. - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - svc -} - -/// Port of `state_service_get_raw_mempool` (zebrad): the RPC and Direct -/// indexers agree on `get_raw_mempool` while two sends sit unmined. -/// -/// Guards the single-source rule the mempool rework rests on: the mempool must -/// be read through the JSON-RPC `mempool_fetcher` on *both* backends, so that -/// the set and the validator tip it is tagged with come from one place. Routing -/// the Direct backend's mempool through `ReadStateService` instead would show up -/// here as the two views disagreeing. -async fn get_raw_mempool_fetch_vs_state() { - let mut svc = fund_and_fill_mempool_dual().await; - - let mut fetch_service_mempool = svc.fetch_subscriber.get_raw_mempool().await.unwrap(); - let mut state_service_mempool = svc.state_subscriber.get_raw_mempool().await.unwrap(); - - dbg!(&fetch_service_mempool); - fetch_service_mempool.sort(); - - dbg!(&state_service_mempool); - state_service_mempool.sort(); - - assert_eq!(fetch_service_mempool, state_service_mempool); - - svc.test_manager.close().await; -} - -/// Port of `state_service_get_address_transactions_regtest` (zebrad): after a -/// transparent send to the recipient, the state indexer's -/// `get_taddress_transactions` over that taddr yields at least one transaction. -async fn get_address_transactions_regtest() { - use futures::StreamExt as _; - - let (mut svc, _txid_hex, recipient_taddr) = fund_and_send_dual(e2e::Pool::Transparent).await; - - let chain_height = svc.fetch_subscriber.tip_height().await; - dbg!(&chain_height); - - let state_service_txids = svc - .state_subscriber - .get_taddress_transactions(TransparentAddressBlockFilter { - address: recipient_taddr, - range: Some(BlockRange { - start: Some(BlockId { - height: chain_height - 2, - hash: vec![], - }), - end: Some(BlockId { - height: chain_height, - hash: vec![], - }), - pool_types: zaino_testutils::all_pools_i32(), - }), - }) - .await - .unwrap(); - - assert!(state_service_txids.count().await > 0); - - svc.test_manager.close().await; -} - -/// Port of `state_service_…::get_transparent_data_from_compact_block_when_requested` -/// (zebrad): with transparent mining, every compact-block tx carries a -/// transparent vout (the miner's transparent coinbase is the data source), so -/// each vout's `script_pub_key` is non-empty. Needs no wallet client — a pure -/// indexer-against-a-transparent-mined-chain check, so it launches the dual -/// services directly rather than through a `DevtoolClients` fixture. -async fn transparent_data_in_compact_block() { - let mut services = zaino_testutils::launch_state_and_fetch_services_mining_to::( - // The assertion below requires every tx to carry a transparent vout; - // the miner's transparent coinbase is that data source, so coinbase - // must land on the miner taddr. - zaino_testutils::MinerPool::Transparent, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - - services.generate_blocks_and_wait_for_tips(5).await; - - let chain_height = services - .state_subscriber - .get_latest_block() - .await - .unwrap() - .height; - - // NOTE / TODO: Zaino can not currently serve non standard script types in - // compact blocks, because of this it does not return the script pub key for - // the coinbase transaction of the genesis block. For this reason this test - // currently does not fetch the genesis block (start height 1, not 0). - // Issue: https://github.com/zingolabs/zaino/issues/818 - let compact_block_range = zaino_testutils::collect_block_range( - &services.state_subscriber, - 1, - chain_height, - zaino_testutils::all_pools_i32(), - ) - .await; - - for cb in compact_block_range.into_iter() { - for tx in cb.vtx { - dbg!(&tx); - // script pub key of this transaction is not empty - assert!(!tx.vout.first().unwrap().script_pub_key.is_empty()); - } - } - - services.test_manager.close().await; -} - -/// Port of `state_service_get_address_balance` (zebrad): the recipient taddr -/// reports the 250_000 send, and the fetch and state indexers agree. -async fn get_address_balance_fetch_vs_state() { - let (mut svc, _txid_hex, recipient_taddr) = fund_and_send_dual(e2e::Pool::Transparent).await; - - let fetch = svc - .fetch_subscriber - .z_get_address_balance(GetAddressBalanceRequest::new(vec![recipient_taddr.clone()])) - .await - .unwrap(); - let state = svc - .state_subscriber - .z_get_address_balance(GetAddressBalanceRequest::new(vec![recipient_taddr])) - .await - .unwrap(); - - // The fixture sent exactly 250_000 to the recipient taddr. - assert_eq!(u64::from(fetch.balance), 250_000); - assert_eq!(fetch, state); - - svc.test_manager.close().await; -} - -/// Launch transparent-mining state+fetch services, build the devtool faucet, -/// mine `blocks` coinbase blocks to its transparent address, and return the -/// services and that taddr — the dual-backend, devtool analogue of -/// `launch_and_build_faucet_request`'s preamble for the faucet-taddr query -/// tests. The faucet taddr is the abandon-art transparent receiver, which is -/// also the zebrad miner address under transparent mining; the non-vacuity -/// probes in the callers verify that equality empirically. -async fn launch_transparent_and_faucet_taddr( - blocks: u32, -) -> (zaino_testutils::StateAndFetchServices, String) { - let svc = zaino_testutils::launch_state_and_fetch_services_mining_to::( - // These tests query the faucet taddr, which only coinbase funds — - // mining must stay transparent or the queries compare empty against - // empty. - zaino_testutils::MinerPool::Transparent, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - - let clients = e2e::devtool::build_clients( - svc.test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &svc.test_manager.local_net, - ) - .await; - - let faucet_taddr = clients.get_faucet_address("transparent").await; - svc.generate_blocks_and_wait_for_tips(blocks).await; - - (svc, faucet_taddr) -} - -/// Port of `state_service_…::get_taddress_txids` (zebrad, faucet-taddr cluster): -/// the fetch and state indexers agree on `get_address_tx_ids` over the faucet's -/// coinbase taddr. The non-vacuity probe (`!txids.is_empty()`) guards against a -/// silent empty==empty pass and confirms the devtool faucet's transparent -/// receiver equals the zebrad miner address. -async fn get_taddress_txids_faucet_fetch_vs_state() { - let (mut svc, faucet_taddr) = launch_transparent_and_faucet_taddr(100).await; - - let request = GetAddressTxIdsRequest::new(vec![faucet_taddr], Some(2), Some(5)); - let state_service_taddress_txids = svc - .state_subscriber - .get_address_tx_ids(request.clone()) - .await - .unwrap(); - dbg!(&state_service_taddress_txids); - let fetch_service_taddress_txids = svc - .fetch_subscriber - .get_address_tx_ids(request) - .await - .unwrap(); - dbg!(&fetch_service_taddress_txids); - // Non-vacuity probe: the faucet taddr must actually hold coinbase txids in - // range, else the fetch==state assert would pass against empty == empty. - assert!(!fetch_service_taddress_txids.is_empty()); - assert_eq!(fetch_service_taddress_txids, state_service_taddress_txids); - - svc.test_manager.close().await; -} - -/// Port of `state_service_…::get_taddress_balance` (zebrad, faucet-taddr -/// cluster): the fetch and state indexers agree on `get_taddress_balance` over -/// the faucet's coinbase taddr. The non-vacuity probe (`value_zat > 0`) guards -/// against a silent 0==0 pass and confirms the address equality. -async fn get_taddress_balance_faucet_fetch_vs_state() { - let (mut svc, faucet_taddr) = launch_transparent_and_faucet_taddr(5).await; - - let request = AddressList { - addresses: vec![faucet_taddr], - }; - let state_service_taddress_balance = svc - .state_subscriber - .get_taddress_balance(request.clone()) - .await - .unwrap(); - let fetch_service_taddress_balance = svc - .fetch_subscriber - .get_taddress_balance(request) - .await - .unwrap(); - // Non-vacuity probe: the faucet taddr must actually hold coinbase value, - // else the fetch==state assert would pass against 0 == 0. - assert!(fetch_service_taddress_balance.value_zat > 0); - assert_eq!( - fetch_service_taddress_balance, - state_service_taddress_balance - ); - - svc.test_manager.close().await; -} - -/// Port of `state_service_…::get_address_utxos` (faucet cluster, zebrad): the -/// fetch and state indexers agree on `get_address_utxos` over the faucet's -/// coinbase taddr. The non-vacuity probe replaces the original's zingolib -/// `transaction_summaries` wallet cross-check (devtool has no transaction -/// listing) and confirms the faucet taddr actually holds coinbase utxos. -async fn get_address_utxos_faucet_fetch_vs_state() { - let (mut svc, faucet_taddr) = launch_transparent_and_faucet_taddr(5).await; - - let request = GetAddressUtxosArg { - addresses: vec![faucet_taddr], - start_height: 2, - max_entries: 3, - }; - let fetch = svc - .fetch_subscriber - .get_address_utxos(request.clone()) - .await - .unwrap(); - let state = svc - .state_subscriber - .get_address_utxos(request) - .await - .unwrap(); - - assert!(!fetch.address_utxos.is_empty()); - assert_eq!(fetch, state); - - svc.test_manager.close().await; -} - -/// Port of `state_service_…::get_address_utxos_stream` (faucet cluster, zebrad): -/// the streamed `get_address_utxos_stream` agrees between the fetch and state -/// indexers over the faucet's coinbase taddr. -async fn get_address_utxos_stream_faucet_fetch_vs_state() { - use futures::StreamExt as _; - - let (mut svc, faucet_taddr) = launch_transparent_and_faucet_taddr(5).await; - - let request = GetAddressUtxosArg { - addresses: vec![faucet_taddr], - start_height: 2, - max_entries: 3, - }; - let fetch = svc - .fetch_subscriber - .get_address_utxos_stream(request.clone()) - .await - .unwrap() - .map(Result::unwrap) - .collect::>() - .await; - let state = svc - .state_subscriber - .get_address_utxos_stream(request) - .await - .unwrap() - .map(Result::unwrap) - .collect::>() - .await; - - assert!(!fetch.is_empty()); - assert_eq!(fetch, state); - - svc.test_manager.close().await; -} - -/// Launch transparent-mining state+fetch services and mine up to chain height -/// 100 — the dual-backend, devtool analogue of `launch_transparent_with_known_tip`. -/// The block-range edge tests need a known 100-block tip and no wallet client. -async fn launch_transparent_to_height_100() -> zaino_testutils::StateAndFetchServices { - let svc = zaino_testutils::launch_state_and_fetch_services_mining_to::( - zaino_testutils::MinerPool::Transparent, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - - // The launch already generates blocks; only generate up to height 100. - let chain_height = svc - .state_subscriber - .get_latest_block() - .await - .unwrap() - .height as u32; - svc.generate_blocks_and_wait_for_tips(100 - chain_height) - .await; - - svc -} - -/// Port of `state_service_get_block_range_out_of_range_test_upper_bound` -/// (zebrad): draining [1, 106] on a 100-block chain yields the 100 available -/// blocks (fetch == state) and then errors rather than ending cleanly. -async fn get_block_range_out_of_range_upper_bound() { - let mut services = launch_transparent_to_height_100().await; - - let all_pools = zaino_testutils::all_pools_i32(); - let end_height: u64 = 106; - - let (fetch_service_blocks, fetch_errored) = zaino_testutils::drain_block_range( - &services.fetch_subscriber, - 1, - end_height, - all_pools.clone(), - ) - .await; - let (state_service_blocks, state_errored) = - zaino_testutils::drain_block_range(&services.state_subscriber, 1, end_height, all_pools) - .await; - - // check that the block range is the same - assert_eq!(fetch_service_blocks, state_service_blocks); - - let compact_block = state_service_blocks.last().unwrap(); - assert!(compact_block.height < end_height); - assert_eq!(fetch_service_blocks.len(), 100); - - // ...then an error, not a clean end-of-stream - assert!( - state_errored, - "state service stream should terminate with an error, not cleanly" - ); - assert!( - fetch_errored, - "fetch service stream should terminate with an error, not cleanly" - ); - - services.test_manager.close().await; -} - -/// Port of `state_service_get_block_range_out_of_range_test_lower_bound` -/// (zebrad): draining the inverted range [106, 1] yields no blocks (fetch == -/// state, both empty) and then errors rather than ending cleanly. -async fn get_block_range_out_of_range_lower_bound() { - let mut services = launch_transparent_to_height_100().await; - - let all_pools = zaino_testutils::all_pools_i32(); - - let (fetch_service_blocks, fetch_errored) = - zaino_testutils::drain_block_range(&services.fetch_subscriber, 106, 1, all_pools.clone()) - .await; - let (state_service_blocks, state_errored) = - zaino_testutils::drain_block_range(&services.state_subscriber, 106, 1, all_pools).await; - - // check that the block range is the same - assert_eq!(fetch_service_blocks, state_service_blocks); - assert!(fetch_service_blocks.is_empty()); - - // ...then an error, not a clean end-of-stream - assert!( - state_errored, - "state service stream should terminate with an error, not cleanly" - ); - assert!( - fetch_errored, - "fetch service stream should terminate with an error, not cleanly" - ); - - services.test_manager.close().await; -} - -/// Port of `send_to_transparent` (wallet_to_validator, heavy/finalization, -/// zebrad): a transparent send to the recipient returns the same address txids -/// whether served from the non-finalized chain (just mined) or after the -/// seam-deep advance pushes the send past the finalization boundary -/// (the seam depth `FAST_TEST_MAX_NONFINALISED_DEPTH` under `fast-test-seam`). -/// -/// `#[ignore]`d (gated): the load-bearing seam-deep advance mines orchard -/// coinbase here — the devtool faucet funds via orchard, since the original's -/// transparent-mine + shield path needs devtool transparent-coinbase shielding -/// — so it costs ~100 halo2 proofs, against the net-speedup -/// criterion. The miner pool is fixed at launch and `generate_blocks` has no -/// per-call override, so the advance can't be cheap transparent filler until -/// per-call filler mining lands; un-ignore and mine the advance to -/// a transparent throwaway then. -async fn send_to_transparent_finalization() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (test_manager, clients) = launch_and_fund_faucet::(1).await; - e2e::devtool::assert_send_to_transparent_finalization(test_manager, clients).await; -} - -/// Port of `zebra::get::address_deltas` (zebrad): `getaddressdeltas` over a -/// transparent-mined chain where the faucet shields its matured transparent -/// coinbase, then sends transparent to the recipient. -/// -/// Gated `devtool-incompatible` (the wrapper in `mod zebrad` carries the -/// `#[ignore]`): the `shield_faucet` step below is blocked by a confirmed -/// devtool bug. `shield` drains *all* the faucet's transparent funds, so it -/// always includes the just-mined `tip-99` coinbase; devtool computes coinbase -/// maturity against the target height (tip+1) while zebra's mempool enforces it -/// against the current tip, so that coinbase is immature by exactly one block -/// and zebra rejects the shield ("immature transparent coinbase spend"). Mining -/// more cannot fix it — the boundary tracks the tip. The bounded `send` tests -/// pass because a send selects only enough *oldest, mature* coinbase to cover -/// the amount, never reaching the immature tip. Heights are derived from the -/// live chain (not the original's hardcoded 102/104) so that once devtool fixes -/// the off-by-one, the only failure mode is the shield, not setup-height drift. -async fn address_deltas() { - use zaino_primitives::types::{ - rpc::{AddressDeltas, AddressDeltasRequest}, - TransparentAddress, - }; - - /// Wraps plain address strings in the domain's address type. - fn taddrs(addresses: &[String]) -> Vec { - addresses - .iter() - .map(|address| TransparentAddress::new(address.clone())) - .collect() - } - - const NON_EXISTENT_ADDRESS: &str = "tmVqEASZxBNKFTbmASZikGa5fPLkd68iJyx"; - - let mut svc = zaino_testutils::launch_state_and_fetch_services_mining_to::( - // The deltas under test are the faucet taddr's coinbase credits and the - // shield's debit — coinbase must land on the miner taddr. - zaino_testutils::MinerPool::Transparent, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - let mut clients = e2e::devtool::build_clients( - svc.test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &svc.test_manager.local_net, - ) - .await; - - let recipient_taddr = clients.get_recipient_address("transparent").await; - let faucet_taddr = clients.get_faucet_address("transparent").await; - - // Mature the faucet's transparent coinbase past the 100-block maturity, then - // shield it (the shield's debit on the faucet taddr is a delta under test, - // and shielding transparent coinbase is the devtool capability exercised). - // Mine generously: the faucet's earliest coinbase sits a few blocks past - // genesis, so its 100-block maturity needs the tip well above height ~110; - // 150 leaves comfortable margin regardless of the launch's startup height. - svc.generate_blocks_and_wait_for_tips(150).await; - clients.sync_faucet().await; - clients.shield_faucet().await; - svc.generate_blocks_and_wait_for_tips(1).await; - - clients.send_from_faucet(&recipient_taddr, 250_000).await; - svc.generate_blocks_and_wait_for_tips(1).await; - clients.sync_recipient().await; - - // Derived heights: the send lands in the tip block. - let chain_tip = svc.fetch_subscriber.tip_height().await as u32; - let tx_height = chain_tip; - let height_beyond_tip = chain_tip + 100; - - // 1) Simple query (single address) -> Simple variant with the send delta. - let response = svc - .state_subscriber - .get_address_deltas(AddressDeltasRequest::Address(TransparentAddress::new( - recipient_taddr.clone(), - ))) - .await - .unwrap(); - let AddressDeltas::Simple(deltas) = response else { - panic!("Expected Simple variant"); - }; - let recipient_delta = deltas - .iter() - .find(|d| u32::from(d.height) >= tx_height) - .expect("Should find recipient transaction delta"); - assert_eq!(recipient_delta.index, 0, "Expected output index 0"); - - // 2) Filtered with start=0 -> Simple variant, deltas from both addresses. - let response = svc - .state_subscriber - .get_address_deltas(AddressDeltasRequest::Filtered { - addresses: taddrs(&[recipient_taddr.clone(), faucet_taddr.clone()]), - start: 0, - end: chain_tip, - chain_info: true, - }) - .await - .unwrap(); - let AddressDeltas::Simple(deltas) = response else { - panic!("Expected Simple variant for start=0"); - }; - assert!(deltas.len() >= 2, "Expected deltas from multiple addresses"); - - // 3) Filtered with start>0 and chain_info -> WithChainInfo variant. - let response = svc - .state_subscriber - .get_address_deltas(AddressDeltasRequest::Filtered { - addresses: taddrs(&[recipient_taddr.clone(), faucet_taddr.clone()]), - start: 1, - end: chain_tip, - chain_info: true, - }) - .await - .unwrap(); - let AddressDeltas::WithChainInfo { deltas, start, end } = response else { - panic!("Expected WithChainInfo variant"); - }; - assert!(!deltas.is_empty(), "Expected deltas with chain info"); - assert_eq!( - u32::from(start.height), - 1, - "Start block should match request" - ); - assert_eq!( - u32::from(end.height), - chain_tip, - "End block should match request" - ); - - // 4) Height clamping: end beyond the tip is clamped down to the tip. - let response = svc - .state_subscriber - .get_address_deltas(AddressDeltasRequest::Filtered { - addresses: taddrs(&[recipient_taddr, faucet_taddr]), - start: 1, - end: height_beyond_tip, - chain_info: true, - }) - .await - .unwrap(); - let AddressDeltas::WithChainInfo { deltas, start, end } = response else { - panic!("Expected WithChainInfo variant"); - }; - assert!(!deltas.is_empty(), "Expected deltas with clamped range"); - assert_eq!(u32::from(start.height), 1, "Start should match request"); - assert!( - u32::from(end.height) < height_beyond_tip, - "End height should be clamped below the requested value" - ); - - // 5) Non-existent address -> empty deltas. - let response = svc - .state_subscriber - .get_address_deltas(AddressDeltasRequest::Filtered { - addresses: taddrs(&[NON_EXISTENT_ADDRESS.to_string()]), - start: 1, - end: height_beyond_tip, - chain_info: true, - }) - .await - .unwrap(); - let AddressDeltas::WithChainInfo { deltas, .. } = response else { - panic!("Expected WithChainInfo variant"); - }; - assert!( - deltas.is_empty(), - "Non-existent address should have no deltas" - ); - - svc.test_manager.close().await; -} - -/// Regression coverage for AP-03 / Zellic #48500: the State backend used to -/// silently drop every *non-coinbase transparent spend input* from -/// `get_block_deltas`, because the verbosity-2 transaction object from Zebra's -/// stateless `TransactionObject::from_transaction` leaves an input's -/// value/address unset. The fix resolves each spend's prevout via Zebra's -/// `ReadStateService` `Transaction` request and reads the spent output's -/// value/address. -/// -/// The faucet pays the recipient a transparent output; the recipient then -/// shields it, producing a non-coinbase transparent input that references the -/// funding output. The spend block's `InputDelta` must be present and resolve -/// to the funding output's address and full (negated) value — pre-fix this -/// input was dropped and `inputs` was empty, so balances overstated funds. -/// -/// State-backend only: zebra serves no `getblockdeltas` RPC, so the fetch -/// backend cannot answer it and there is no fetch-vs-state cross-check (cf. -/// `address_deltas`). Funding via the shielded pool ([`SHIELDED_FUNDING_POOL`]) -/// makes the spend under test the recipient shielding a *received* output, so -/// the test needs no transparent-coinbase maturity ritual and does not depend -/// on the `shield_faucet` path that gates `address_deltas` behind -/// `devtool-incompatible` (it shields the recipient's send receipt, the -/// not-ignored `shield_for_validator` path). -async fn get_block_deltas_resolves_transparent_spend() { - // The only transparent output in the funding block: the (shielded) coinbase - // pays the orchard pool and the faucet's change returns to orchard, so the - // funding output is uniquely identifiable by its amount. - const FUNDING_AMOUNT: i64 = 250_000; - - let mut svc = zaino_testutils::launch_state_and_fetch_services_mining_to::( - zaino_testutils::SHIELDED_FUNDING_POOL, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - let mut clients = e2e::devtool::build_clients( - svc.test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &svc.test_manager.local_net, - ) - .await; - - // One orchard coinbase note for the faucet, then fund the recipient's - // transparent address with a non-coinbase transparent output. - svc.generate_blocks_and_wait_for_tips(1).await; - clients.sync_faucet().await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - clients - .send_from_faucet(&recipient_taddr, FUNDING_AMOUNT as u64) - .await; - svc.generate_blocks_and_wait_for_tips(1).await; - let funding_block_hash = svc - .state_subscriber - .get_best_blockhash() - .await - .unwrap() - .hash() - .to_string(); - - // The recipient confirms the received output and shields it; the shielding - // tx spends that output, producing the non-coinbase transparent input under - // test. - clients.sync_recipient().await; - clients.shield_recipient().await; - svc.generate_blocks_and_wait_for_tips(1).await; - let spend_block_hash = svc - .state_subscriber - .get_best_blockhash() - .await - .unwrap() - .hash() - .to_string(); - - // Locate the funding output (unique by amount). Its txid and address come - // from the state backend, as do the spend input's `prevtxid`/`address` - // below, so the cross-block match stays within one backend's encoding. - let funding_deltas = svc - .state_subscriber - .get_block_deltas(funding_block_hash) - .await - .unwrap(); - let funding_delta = funding_deltas - .deltas - .iter() - .find(|d| { - d.outputs - .iter() - .any(|o| i64::try_from(u64::from(o.satoshis)) == Ok(FUNDING_AMOUNT)) - }) - .expect("funding tx paying the recipient should be in its block"); - let funding_output = funding_delta - .outputs - .iter() - .find(|o| i64::try_from(u64::from(o.satoshis)) == Ok(FUNDING_AMOUNT)) - .expect("funding output paying the recipient should be present"); - let funding_txid = funding_delta.txid; - let funding_vout = funding_output.index; - let funding_address = funding_output.address.clone(); - - // The spend's input must be present and resolved to the funding output's - // address and full value (negative — it is a debit). Pre-fix `inputs` was - // empty and this lookup would fail. - let spend_deltas = svc - .state_subscriber - .get_block_deltas(spend_block_hash) - .await - .unwrap(); - let input = spend_deltas - .deltas - .iter() - .flat_map(|d| d.inputs.iter()) - .find(|i| i.prev_txid == funding_txid && i.prev_output == funding_vout) - .expect("spend input referencing the funding output should be present"); - - assert_eq!( - input.address, funding_address, - "input must resolve to the prevout's address" - ); - assert_eq!( - i64::from(input.satoshis), - -FUNDING_AMOUNT, - "input must resolve to the prevout's full value, negated" - ); - - svc.test_manager.close().await; -} - -/// A freshly mined block carries only its (shielded) coinbase transaction: the -/// coinbase input is skipped and `get_block_deltas` fabricates no transparent -/// input deltas. State-backend only (cf. -/// `get_block_deltas_resolves_transparent_spend`). -async fn get_block_deltas_coinbase_only_block_has_no_inputs() { - let mut svc = zaino_testutils::launch_state_and_fetch_services_mining_to::( - zaino_testutils::SHIELDED_FUNDING_POOL, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - - svc.generate_blocks_and_wait_for_tips(1).await; - let block_hash = svc - .state_subscriber - .get_best_blockhash() - .await - .unwrap() - .hash() - .to_string(); - - let deltas = svc - .state_subscriber - .get_block_deltas(block_hash) - .await - .unwrap(); - - assert!( - deltas.deltas.iter().all(|d| d.inputs.is_empty()), - "a coinbase-only block must have no input deltas" - ); - - svc.test_manager.close().await; -} - -/// The recipient's sole transparent outpoint, read from the chain index. Each -/// phase of [`get_outpoint_spenders_fetch_vs_state`] drains the recipient's -/// transparent funds (by shielding) before funding it again, so at the call -/// site exactly one UTXO — the funding under test — sits at `recipient_taddr` -/// (the miner pays coinbase to the shielded pool, never this address). -async fn sole_recipient_outpoint( - indexer: &zaino_state::NodeBackedChainIndexSubscriber, - recipient_taddr: &str, -) -> zaino_state::chain_index::types::Outpoint { - use zaino_state::ChainIndex as _; - - let utxos = indexer - .get_address_utxos(GetAddressBalanceRequest::new(vec![ - recipient_taddr.to_string() - ])) - .await - .unwrap(); - assert_eq!( - utxos.len(), - 1, - "recipient taddr should hold exactly one funding UTXO" - ); - let (txid, output_index) = (utxos[0].txid, utxos[0].output_index); - zaino_state::chain_index::types::Outpoint::new(<[u8; 32]>::from(txid), output_index) -} - -/// The single transaction touching `recipient_taddr` at `height` — the shield -/// that spent the funding outpoint (the credit landed in an earlier block). -/// Zebra's address index records a tx against an address when the address is an -/// input *or* an output, so the shield's spend of the recipient's UTXO appears -/// here. This is the independent ground truth for the expected spender txid, -/// in the same `TransactionHash` type `get_outpoint_spenders` returns. -async fn sole_spender_at( - indexer: &zaino_state::NodeBackedChainIndexSubscriber, - recipient_taddr: &str, - height: u32, -) -> zaino_state::chain_index::types::TransactionHash { - use zaino_state::ChainIndex as _; - - let txids = indexer - .get_address_txids(GetAddressTxIdsRequest::new( - vec![recipient_taddr.to_string()], - Some(height), - Some(height), - )) - .await - .unwrap(); - assert_eq!( - txids.len(), - 1, - "exactly one tx (the shield) should touch the taddr at the spend height" - ); - txids[0] -} - -/// Funds `recipient_taddr` with a single fresh UTXO of `amount`, mines it in, syncs -/// both wallets, and returns that outpoint. Leaves exactly one UTXO at the address -/// (see [`sole_recipient_outpoint`]). -async fn fund_recipient( - svc: &mut zaino_testutils::StateAndFetchServices, - clients: &mut e2e::devtool::DevtoolClients, - recipient_taddr: &str, - amount: u64, -) -> zaino_state::chain_index::types::Outpoint { - clients.sync_faucet().await; - clients.send_from_faucet(recipient_taddr, amount).await; - svc.generate_blocks_and_wait_for_tips(1).await; - clients.sync_recipient().await; - sole_recipient_outpoint(&svc.fetch_subscriber.indexer, recipient_taddr).await -} - -/// Shields the recipient's transparent funds — spending its sole UTXO — mines the -/// shield in, and returns the txid that spent it (see [`sole_spender_at`]). -async fn spend_and_record( - svc: &mut zaino_testutils::StateAndFetchServices, - clients: &mut e2e::devtool::DevtoolClients, - recipient_taddr: &str, -) -> zaino_state::chain_index::types::TransactionHash { - clients.shield_recipient().await; - svc.generate_blocks_and_wait_for_tips(1).await; - let spend_height = svc.fetch_subscriber.tip_height().await as u32; - sole_spender_at(&svc.fetch_subscriber.indexer, recipient_taddr, spend_height).await -} - -/// Rewrite of `zebra::get::chain_cache::get_outpoint_spenders` (retired with the -/// zingolib wallet harness) for the devtool wallet + `StateAndFetchServices` -/// structure. End-to-end check of `ChainIndex::get_outpoint_spenders` and its -/// `ChainScope` against a real regtest chain, driven through both backends. -/// -/// Builds three transparent outpoints at the recipient address — one spent and -/// buried past the finalised floor, one spent but left in the non-finalised -/// window, and one left unspent — then asserts both `ChainScope`s resolve each -/// correctly: `FullChain` sees both spends, `Finalised` sees only the buried -/// one. Asserting the fetch and state indexers return the same result also -/// covers the `*_fetch_vs_state` agreement. This is the only place the -/// finalised `TxLocation -> txid` resolution runs end-to-end (the in-tree -/// mockchain vectors spend nothing below the floor, and proptest blocks can't -/// be finalised). -/// -/// Each spend is a `shield_recipient` of the recipient's *received* transparent -/// output (the non-ignored `shield_for_validator` path), not a faucet-coinbase -/// shield, so it sidesteps the devtool coinbase-maturity bug that gates -/// `address_deltas`. The shield drains all transparent funds, so the recipient -/// holds exactly one UTXO per phase and the spent outpoint is unambiguous. -async fn get_outpoint_spenders_fetch_vs_state() { - use zaino_state::chain_index::types::ChainScope; - use zaino_state::ChainIndex as _; - - // Bury a spend this many blocks past its block to push it below the finalised - // floor (tip − seam depth) so `ChainScope::Finalised` can resolve it. This crate - // enables `fast-test-seam`, so the live zaino-state uses the tractable - // `FAST_TEST_MAX_NONFINALISED_DEPTH`; a small margin above it keeps the boundary - // unambiguous. (Without the feature the real seam is ~1000, impractical to mine - // here — see zingolabs/zaino#1352.) - const FINALITY_DEPTH: u32 = zaino_consensus::FAST_TEST_MAX_NONFINALISED_DEPTH + 5; - const FUNDING_AMOUNT: u64 = 250_000; - - let mut svc = zaino_testutils::launch_state_and_fetch_services_mining_to::( - zaino_testutils::SHIELDED_FUNDING_POOL, - &ValidatorKind::Zebrad, - None, - true, - Some(zebra_chain::parameters::NetworkKind::Regtest), - ) - .await; - let mut clients = e2e::devtool::build_clients( - svc.test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &svc.test_manager.local_net, - ) - .await; - - let recipient_taddr = clients.get_recipient_address("transparent").await; - - // ---- Phase 1: an outpoint that is SPENT and FINALISED ---- - let outpoint_finalised = - fund_recipient(&mut svc, &mut clients, &recipient_taddr, FUNDING_AMOUNT).await; - let spender_finalised = spend_and_record(&mut svc, &mut clients, &recipient_taddr).await; - - // Bury the spend below the finalised floor. - svc.generate_blocks_and_wait_for_tips(FINALITY_DEPTH).await; - - // ---- Phase 2: an outpoint that is SPENT but stays NON-FINALISED ---- - let outpoint_nonfinalised = - fund_recipient(&mut svc, &mut clients, &recipient_taddr, FUNDING_AMOUNT).await; - let spender_nonfinalised = spend_and_record(&mut svc, &mut clients, &recipient_taddr).await; - - // ---- Phase 3: an outpoint that is created but left UNSPENT ---- - let outpoint_unspent = - fund_recipient(&mut svc, &mut clients, &recipient_taddr, FUNDING_AMOUNT).await; - - let outpoints = vec![outpoint_finalised, outpoint_nonfinalised, outpoint_unspent]; - - // Both backends must agree and resolve each scope correctly. - for indexer in [&svc.fetch_subscriber.indexer, &svc.state_subscriber.indexer] { - let snapshot = indexer.snapshot_nonfinalized_state().await.unwrap(); - - // FullChain resolves both the finalised and the non-finalised spend. - let full = indexer - .get_outpoint_spenders(&snapshot, outpoints.clone(), ChainScope::FullChain) - .await - .unwrap(); - assert_eq!( - full, - vec![Some(spender_finalised), Some(spender_nonfinalised), None], - "FullChain must see both spends and leave the unspent outpoint as None" - ); - - // Finalised resolves only the buried spend. - let finalised = indexer - .get_outpoint_spenders(&snapshot, outpoints.clone(), ChainScope::Finalised) - .await - .unwrap(); - assert_eq!( - finalised, - vec![Some(spender_finalised), None, None], - "Finalised must see only the buried spend" - ); - } - - svc.test_manager.close().await; -} - -/// `getmempoolinfo` agrees with the **validator's own** `getmempoolinfo`. -/// -/// This used to recompute the totals from the very entries Zaino had just -/// reported, which only asserted that Zaino equals itself. The arithmetic is -/// covered by mocks in `zaino-mempool-service` -/// (`get_mempool_info_reports_totals`); what a live validator adds is the -/// cross-check that Zaino's mirror of the mempool holds the same transactions -/// the validator does. -async fn get_mempool_info_fetch() { - let (mut test_manager, _transparent_txid, _unified_txid) = fund_and_fill_mempool::().await; - - let info = test_manager.subscriber().get_mempool_info().await.unwrap(); - let validator = test_manager - .full_node_jsonrpc_connector() - .await - .get("getmempoolinfo") - .await; - - assert!(info.size >= 1, "the test funded the mempool"); - assert_eq!( - info.size, - validator["size"].as_u64().expect("validator reports size"), - "Zaino's mempool holds a different number of transactions than the validator's" - ); - assert_eq!( - info.bytes, - validator["bytes"] - .as_u64() - .expect("validator reports bytes"), - "Zaino's serialized-byte total disagrees with the validator's" - ); - // `usage` is deliberately not compared. Zaino reports the ZIP-401 cost total - // — each transaction floored at the cost threshold — because that is the - // figure its own memory bound is enforced against; the validator reports its - // internal heap estimate. Different quantities by design. - assert!(info.usage >= info.bytes); - - test_manager.close().await; -} - -/// As [`get_mempool_info_fetch`], for the Direct (`ReadStateService`) backend. -/// -/// Worth running on both because the mempool is served over JSON-RPC either way -/// — the Direct backend reads it through the same transport — so this pins that -/// the backend choice does not change the reported totals. -async fn get_mempool_info_state() { - let mut svc = fund_and_fill_mempool_dual().await; - - let info = svc.state_subscriber.get_mempool_info().await.unwrap(); - let validator = svc - .test_manager - .full_node_jsonrpc_connector() - .await - .get("getmempoolinfo") - .await; - - assert!(info.size >= 1, "the test funded the mempool"); - assert_eq!( - info.size, - validator["size"].as_u64().expect("validator reports size"), - "Zaino's mempool holds a different number of transactions than the validator's" - ); - assert_eq!( - info.bytes, - validator["bytes"] - .as_u64() - .expect("validator reports bytes"), - "Zaino's serialized-byte total disagrees with the validator's" - ); - // See `get_mempool_info_fetch` for why `usage` is not compared. - assert!(info.usage >= info.bytes); - - svc.test_manager.close().await; -} - -mod zebrad { - mod fetch_service { - use zaino_testutils::Rpc; - - #[tokio::test(flavor = "multi_thread")] - async fn receives_mining_reward() { - crate::receives_mining_reward::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn connect_to_node_get_info() { - crate::connect_to_node_get_info::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_ironwood() { - crate::send_to_pool::(e2e::Pool::Ironwood).await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_sapling() { - crate::send_to_pool::(e2e::Pool::Sapling).await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_transparent() { - crate::send_to_pool::(e2e::Pool::Transparent).await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_all() { - crate::send_to_all::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn shield_for_validator() { - crate::shield_for_validator::().await; - } - - #[tokio::test(flavor = "multi_thread")] - #[cfg_attr( - not(feature = "devtool-incompatible"), - ignore = "heavy: seam-deep orchard advance (~100 halo2 proofs); un-ignore + transparent filler when cheap filler mining lands" - )] - async fn send_to_transparent_finalization() { - crate::send_to_transparent_finalization::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_transaction_mined() { - crate::get_transaction_mined::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_raw_mempool() { - crate::get_raw_mempool::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_mempool_tx() { - crate::get_mempool_tx::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_mempool_stream() { - crate::get_mempool_stream::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_mempool_info() { - crate::get_mempool_info_fetch().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_address_tx_ids() { - crate::get_address_tx_ids::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_address_utxos() { - crate::get_address_utxos::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn z_get_treestate() { - crate::z_get_treestate::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn z_get_subtrees_by_index() { - crate::z_get_subtrees_by_index::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_raw_transaction() { - crate::get_raw_transaction::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_taddress_txids() { - crate::get_taddress_txids::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_taddress_utxos() { - crate::get_taddress_utxos::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_taddress_utxos_stream() { - crate::get_taddress_utxos_stream::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_transaction_mempool() { - crate::get_transaction_mempool::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_address_balance() { - crate::get_address_balance::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_taddress_balance() { - crate::get_taddress_balance::().await; - } - } - - // Span both the fetch and state indexers (compares their answers), so they - // are not per-backend tests. - #[tokio::test(flavor = "multi_thread")] - async fn block_range_returns_default_pools() { - crate::block_range_returns_default_pools().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn block_range_returns_all_pools() { - crate::block_range_returns_all_pools().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn z_get_treestate_fetch_vs_state() { - crate::z_get_treestate_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn z_get_subtrees_by_index_fetch_vs_state() { - crate::z_get_subtrees_by_index_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_raw_transaction_fetch_vs_state() { - crate::get_raw_transaction_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_address_tx_ids_fetch_vs_state() { - crate::get_address_tx_ids_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_address_utxos_fetch_vs_state() { - crate::get_address_utxos_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_raw_mempool_fetch_vs_state() { - crate::get_raw_mempool_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_address_transactions_regtest() { - crate::get_address_transactions_regtest().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn transparent_data_in_compact_block() { - crate::transparent_data_in_compact_block().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_taddress_txids_faucet_fetch_vs_state() { - crate::get_taddress_txids_faucet_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_taddress_balance_faucet_fetch_vs_state() { - crate::get_taddress_balance_faucet_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_address_utxos_faucet_fetch_vs_state() { - crate::get_address_utxos_faucet_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_address_utxos_stream_faucet_fetch_vs_state() { - crate::get_address_utxos_stream_faucet_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - #[cfg_attr( - not(feature = "devtool-incompatible"), - ignore = "devtool `shield` drains all transparent funds, so it always includes the freshly-mined tip-99 coinbase; devtool computes coinbase maturity against the target height (tip+1) while zebra's mempool enforces it against the current tip, so that coinbase is immature by one block and the shield is rejected — un-ignore when devtool fixes the coinbase-maturity off-by-one" - )] - async fn address_deltas() { - crate::address_deltas().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_block_deltas_resolves_transparent_spend() { - crate::get_block_deltas_resolves_transparent_spend().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_block_deltas_coinbase_only_block_has_no_inputs() { - crate::get_block_deltas_coinbase_only_block_has_no_inputs().await; - } - - #[tokio::test(flavor = "multi_thread")] - #[cfg_attr( - not(feature = "devtool-incompatible"), - ignore = "heavy: mines ~105 orchard-coinbase blocks (~105 halo2 proofs) to bury a finalised spend below the seam (FAST_TEST_MAX_NONFINALISED_DEPTH); un-ignore for manual / dedicated CI" - )] - async fn get_outpoint_spenders_fetch_vs_state() { - crate::get_outpoint_spenders_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_address_balance_fetch_vs_state() { - crate::get_address_balance_fetch_vs_state().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_block_range_out_of_range_upper_bound() { - crate::get_block_range_out_of_range_upper_bound().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_block_range_out_of_range_lower_bound() { - crate::get_block_range_out_of_range_lower_bound().await; - } - - mod state_service { - use zaino_testutils::Direct; - - #[tokio::test(flavor = "multi_thread")] - async fn receives_mining_reward() { - crate::receives_mining_reward::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn connect_to_node_get_info() { - crate::connect_to_node_get_info::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_ironwood() { - crate::send_to_pool::(e2e::Pool::Ironwood).await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_sapling() { - crate::send_to_pool::(e2e::Pool::Sapling).await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_transparent() { - crate::send_to_pool::(e2e::Pool::Transparent).await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_all() { - crate::send_to_all::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn shield_for_validator() { - crate::shield_for_validator::().await; - } - - #[tokio::test(flavor = "multi_thread")] - #[cfg_attr( - not(feature = "devtool-incompatible"), - ignore = "heavy: seam-deep orchard advance (~100 halo2 proofs); un-ignore + transparent filler when cheap filler mining lands" - )] - async fn send_to_transparent_finalization() { - crate::send_to_transparent_finalization::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_transaction_mined() { - crate::get_transaction_mined::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_raw_mempool() { - crate::get_raw_mempool::().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_mempool_info() { - crate::get_mempool_info_state().await; - } - - // No get_mempool_tx here: the state backend returns mempool-tx txids - // in display (reversed) byte order while the fetch backend returns - // internal order, so the cross-backend txid comparison fails on - // state (zingolabs/zaino#1225). The original test was FetchService- - // only; re-enable once that bug is fixed. - } -} diff --git a/live-tests/e2e/tests/devtool_zcashd.rs b/live-tests/e2e/tests/devtool_zcashd.rs deleted file mode 100644 index 548b3c92..00000000 --- a/live-tests/e2e/tests/devtool_zcashd.rs +++ /dev/null @@ -1,526 +0,0 @@ -//! Proof-of-concept: a devtool wallet against a **zcashd**-backed Zaino. -//! -//! The zebrad devtool suite (`tests/devtool.rs`) is complete. This isolates the -//! one remaining alignment for the zcashd matrix (the `json_server` oracle tests -//! and the zcashd send/query column): launch zcashd at the same activation -//! heights zebrad uses — `ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS`, which the devtool -//! wallet's compiled-in `supported_regtest_activation_heights` requires (the 46 -//! zebrad tests prove the wallet accepts them) — rather than zcashd's default -//! heights (all = 1), which would mismatch the wallet's consensus branch IDs. -//! -//! zcashd mines ORCHARD coinbase to `REG_O_ADDR_FROM_ABANDONART` (the abandon-art -//! orchard address the devtool faucet owns), so the faucet is funded with no -//! transparent-coinbase shielding — the zcashd matrix is NOT gated on devtool -//! transparent-coinbase shielding, only on this heights alignment. -//! -//! If this passes, the `json_server` tests port by swapping their zingolib -//! funding for `DevtoolClients` while keeping the zaino-vs-zcashd oracle -//! comparison. If zcashd rejects the heights (e.g. the NU6.1 lockbox gotcha, -//! which bit zebrad), that's the blocker to resolve before porting the mod. - -// The entire zcashd matrix depends on the zcashd validator + its zaino-testutils -// launchers, all gated behind `zcashd_support`. Gate the whole binary so it -// compiles out under `--no-default-features` (mirrors the clientless partition's json_server.rs). -#![cfg(feature = "zcashd_support")] -use e2e::devtool::DevtoolClients; -use zaino_state::{ChainIndex, ZcashIndexer}; -use zaino_testutils::{Rpc, TestManager, ValidatorKind, ZcashdDualFetchServices}; -use zcash_local_net::validator::zcashd::Zcashd; -use zebra_chain::subtree::NoteCommitmentSubtreeIndex; -use zebra_rpc::client::GetAddressBalanceRequest; -use zebra_rpc::methods::GetAddressTxIdsRequest; - -/// Launch zcashd (orchard-mining) at the devtool-compatible activation heights -/// (`ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS`, which the PoC below proves zcashd -/// accepts and the devtool wallet requires) and build the devtool -/// faucet/recipient wallets against the resulting Zaino, without mining or -/// syncing. The zcashd analogue of devtool.rs's `launch_and_build_clients`, -/// concrete on zcashd (which has no StateService backend). -async fn launch_zcashd_and_build_clients() -> (TestManager, DevtoolClients) { - e2e::devtool::launch_and_build_devtool_clients( - &ValidatorKind::Zcashd, - // The heights the devtool wallet accepts (same as the zebrad path). - Some(zaino_testutils::ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS), - ) - .await -} - -/// [`launch_zcashd_and_build_clients`] plus `orchard_notes` orchard coinbase -/// notes for the faucet, synced. One block more than `orchard_notes` is mined -/// because the height-1 coinbase is sapling at nu5=2 (orchard accrues from -/// height 2). The send/shield analogue of devtool.rs's `launch_and_fund_faucet`. -async fn launch_and_fund_zcashd_faucet( - orchard_notes: u32, -) -> (TestManager, DevtoolClients) { - let (test_manager, mut clients) = launch_zcashd_and_build_clients().await; - test_manager - .generate_blocks_and_wait_for_tip(orchard_notes + 1, test_manager.subscriber()) - .await; - clients.sync_faucet().await; - (test_manager, clients) -} - -/// Launch zcashd, fund the faucet with two orchard coinbase notes, and assert -/// the faucet sees them — the PoC that proved zcashd accepts the -/// devtool-compatible heights (no NU6.1 lockbox rejection) and the abandon-art -/// faucet sees zcashd's orchard coinbase. -#[tokio::test(flavor = "multi_thread")] -async fn faucet_receives_zcashd_orchard_reward() { - let (mut test_manager, mut clients) = launch_zcashd_and_build_clients().await; - - // Two orchard coinbase notes for the abandon-art faucet. - test_manager - .generate_blocks_and_wait_for_tip(2, test_manager.subscriber()) - .await; - clients.sync_faucet().await; - - let balance = clients.faucet_balance().await; - dbg!(&balance); - assert!( - balance.orchard_spendable > 0, - "devtool faucet should see zcashd's orchard coinbase" - ); - - test_manager.close().await; -} - -/// Launch zcashd dual fetch services at the devtool-compatible activation -/// heights (`ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS`, which the PoC above proves -/// zcashd accepts and the devtool wallet requires) and build the devtool -/// faucet/recipient wallets against the resulting Zaino — the devtool analogue -/// of json_server's `create_zcashd_test_manager_and_fetch_services`. -async fn create_zcashd_devtool_services() -> (ZcashdDualFetchServices, DevtoolClients) { - let services = zaino_testutils::launch_zcashd_dual_fetch_services_at( - zaino_testutils::ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS, - ) - .await; - let clients = e2e::devtool::build_clients( - services - .test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - ) - .await; - (services, clients) -} - -/// Devtool analogue of json_server's `jsonrpc_fund`: fund the faucet with orchard -/// coinbase notes, sync, fetch the recipient's transparent + unified addresses, -/// and if `send` is `Some(pool)`, send 250_000 to that pool's recipient address -/// and mine it in. Returns `(recipient_taddr, recipient_ua, sent_txid_hex)`. The -/// send=None mempool tests broadcast two unmined sends, so they need two notes. -async fn jsonrpc_fund( - services: &ZcashdDualFetchServices, - clients: &mut DevtoolClients, - send: Option, -) -> (String, String, Option) { - // At nu5=2 the height-1 coinbase is sapling and orchard notes only accrue - // from height 2, so mine one block more than the orchard notes needed (1 per - // send: a Some(pool) test spends one note, the send=None mempool tests two). - let notes: u32 = if send.is_some() { 2 } else { 3 }; - services.generate_blocks_and_wait_for_tips(notes).await; - clients.sync_faucet().await; - - let recipient_taddr = clients.get_recipient_address("transparent").await; - let recipient_ua = clients.get_recipient_address("unified").await; - - let sent = if let Some(pool) = send { - let addr = clients.get_recipient_address(pool.address_kind()).await; - let txid = clients.send_from_faucet(&addr, 250_000).await; - services.generate_blocks_and_wait_for_tips(1).await; - Some(txid.trim().to_string()) - } else { - None - }; - - (recipient_taddr, recipient_ua, sent) -} - -/// Devtool ports of the `json_server` oracle tests: zaino's answer (through its -/// JSON-RPC server, `zaino_subscriber`) must equal zcashd's own answer -/// (`zcashd_subscriber`). Verbatim from `tests/json_server.rs` except the -/// funding (devtool, not zingolib) and the sent txid (devtool's display-order -/// hex `String`, which matches the txid strings these RPCs return). -mod json_server { - use super::*; - - #[tokio::test(flavor = "multi_thread")] - async fn z_get_address_balance() { - let (mut services, mut clients) = create_zcashd_devtool_services().await; - - let (recipient_taddr, _recipient_ua, _txid) = - jsonrpc_fund(&services, &mut clients, Some(e2e::Pool::Transparent)).await; - - let zcashd_service_balance = services - .zcashd_subscriber - .z_get_address_balance(GetAddressBalanceRequest::new(vec![recipient_taddr.clone()])) - .await - .unwrap(); - let zaino_service_balance = services - .zaino_subscriber - .z_get_address_balance(GetAddressBalanceRequest::new(vec![recipient_taddr])) - .await - .unwrap(); - - dbg!(&zcashd_service_balance); - dbg!(&zaino_service_balance); - - // The fixture sent exactly 250_000 to the recipient taddr. - assert_eq!(zcashd_service_balance.balance, 250_000); - assert_eq!(zcashd_service_balance, zaino_service_balance); - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_raw_mempool() { - let (mut services, mut clients) = create_zcashd_devtool_services().await; - - let (recipient_taddr, recipient_ua, _txid) = - jsonrpc_fund(&services, &mut clients, None).await; - clients.send_from_faucet(&recipient_taddr, 250_000).await; - clients.send_from_faucet(&recipient_ua, 250_000).await; - - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - - let mut zcashd_mempool = services.zcashd_subscriber.get_raw_mempool().await.unwrap(); - let mut zaino_mempool = services.zaino_subscriber.get_raw_mempool().await.unwrap(); - - dbg!(&zcashd_mempool); - zcashd_mempool.sort(); - dbg!(&zaino_mempool); - zaino_mempool.sort(); - - assert_eq!(zcashd_mempool, zaino_mempool); - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_mempool_info() { - let (mut services, mut clients) = create_zcashd_devtool_services().await; - - let (recipient_taddr, recipient_ua, _txid) = - jsonrpc_fund(&services, &mut clients, None).await; - clients.send_from_faucet(&recipient_taddr, 250_000).await; - clients.send_from_faucet(&recipient_ua, 250_000).await; - - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - - let zcashd_info = services.zcashd_subscriber.get_mempool_info().await.unwrap(); - let zaino_info = services.zaino_subscriber.get_mempool_info().await.unwrap(); - - assert_eq!(zcashd_info, zaino_info); - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn z_get_treestate() { - let (mut services, mut clients) = create_zcashd_devtool_services().await; - - jsonrpc_fund(&services, &mut clients, Some(e2e::Pool::Orchard)).await; - - let chain_height = dbg!(services.zaino_subscriber.chain_height().await.unwrap()).0; - - let zcashd_treestate = dbg!(services - .zcashd_subscriber - .z_get_treestate(chain_height.to_string()) - .await - .unwrap()); - let zaino_treestate = dbg!(services - .zaino_subscriber - .z_get_treestate(chain_height.to_string()) - .await - .unwrap()); - - assert_eq!(zcashd_treestate, zaino_treestate); - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn z_get_subtrees_by_index() { - let (mut services, mut clients) = create_zcashd_devtool_services().await; - - jsonrpc_fund(&services, &mut clients, Some(e2e::Pool::Orchard)).await; - - let zcashd_subtrees = dbg!(services - .zcashd_subscriber - .z_get_subtrees_by_index( - zaino_primitives::types::ShieldedPool::Orchard, - NoteCommitmentSubtreeIndex(0), - None - ) - .await - .unwrap()); - let zaino_subtrees = dbg!(services - .zaino_subscriber - .z_get_subtrees_by_index( - zaino_primitives::types::ShieldedPool::Orchard, - NoteCommitmentSubtreeIndex(0), - None - ) - .await - .unwrap()); - - assert_eq!(zcashd_subtrees, zaino_subtrees); - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_raw_transaction() { - let (mut services, mut clients) = create_zcashd_devtool_services().await; - - let (_recipient_taddr, _recipient_ua, tx) = - jsonrpc_fund(&services, &mut clients, Some(e2e::Pool::Orchard)).await; - let tx = tx.expect("jsonrpc_fund sends a tx when given Some(pool)"); - - let zcashd_transaction = dbg!(services - .zcashd_subscriber - .get_raw_transaction(tx.clone(), Some(1)) - .await - .unwrap()); - let zaino_transaction = dbg!(services - .zaino_subscriber - .get_raw_transaction(tx, Some(1)) - .await - .unwrap()); - - assert_eq!(zcashd_transaction, zaino_transaction); - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_tx_out() { - let (mut services, mut clients) = create_zcashd_devtool_services().await; - - let (recipient_taddr, _recipient_ua, _txid) = - jsonrpc_fund(&services, &mut clients, Some(e2e::Pool::Transparent)).await; - - let zcashd_utxos = services - .zcashd_subscriber - .z_get_address_utxos(GetAddressBalanceRequest::new(vec![recipient_taddr.clone()])) - .await - .unwrap(); - let (txid, output_index) = (zcashd_utxos[0].txid, zcashd_utxos[0].output_index); - - let zcashd_tx_out = services - .zcashd_subscriber - .get_tx_out(txid.to_string(), output_index.index(), Some(true)) - .await - .unwrap(); - let zaino_tx_out = services - .zaino_subscriber - .get_tx_out(txid.to_string(), output_index.index(), Some(true)) - .await - .unwrap(); - - assert_eq!(zcashd_tx_out, zaino_tx_out); - - let zcashd_missing_tx_out = services - .zcashd_subscriber - .get_tx_out(txid.to_string(), output_index.index() + 100, None) - .await - .unwrap(); - let zaino_missing_tx_out = services - .zaino_subscriber - .get_tx_out(txid.to_string(), output_index.index() + 100, None) - .await - .unwrap(); - - assert_eq!(zcashd_missing_tx_out, zaino_missing_tx_out); - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn get_address_tx_ids() { - let (mut services, mut clients) = create_zcashd_devtool_services().await; - - let (recipient_taddr, _recipient_ua, tx) = - jsonrpc_fund(&services, &mut clients, Some(e2e::Pool::Transparent)).await; - let tx = tx.expect("jsonrpc_fund sends a tx when given Some(pool)"); - - let chain_height: u32 = { - let idx = &services.zcashd_subscriber.indexer; - let snapshot = idx.snapshot_nonfinalized_state().await.unwrap(); - u32::from(idx.best_chaintip(&snapshot).await.unwrap().height) - }; - dbg!(&chain_height); - - let zcashd_txids = services - .zcashd_subscriber - .get_address_tx_ids(GetAddressTxIdsRequest::new( - vec![recipient_taddr.clone()], - Some(chain_height - 2), - Some(chain_height), - )) - .await - .unwrap(); - let zaino_txids = services - .zaino_subscriber - .get_address_tx_ids(GetAddressTxIdsRequest::new( - vec![recipient_taddr], - Some(chain_height - 2), - Some(chain_height), - )) - .await - .unwrap(); - - dbg!(&tx); - dbg!(&zcashd_txids); - assert_eq!(tx, zcashd_txids[0]); - - dbg!(&zaino_txids); - assert_eq!(zcashd_txids, zaino_txids); - - services.test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn z_get_address_utxos() { - let (mut services, mut clients) = create_zcashd_devtool_services().await; - - let (recipient_taddr, _recipient_ua, txid_1) = - jsonrpc_fund(&services, &mut clients, Some(e2e::Pool::Transparent)).await; - let txid_1 = txid_1.expect("jsonrpc_fund sends a tx when given Some(pool)"); - - clients.sync_faucet().await; - - let zcashd_utxos = services - .zcashd_subscriber - .z_get_address_utxos(GetAddressBalanceRequest::new(vec![recipient_taddr.clone()])) - .await - .unwrap(); - let zcashd_txid = zcashd_utxos[0].txid; - - let zaino_utxos = services - .zaino_subscriber - .z_get_address_utxos(GetAddressBalanceRequest::new(vec![recipient_taddr])) - .await - .unwrap(); - let zaino_txid = zaino_utxos[0].txid; - - dbg!(&txid_1); - dbg!(&zcashd_utxos); - assert_eq!(txid_1, zcashd_txid.to_string()); - - dbg!(&zaino_utxos); - assert_eq!(zcashd_txid.to_string(), zaino_txid.to_string()); - - services.test_manager.close().await; - } -} - -/// zcashd analogue of devtool.rs's `send_to_pool`: the faucet sends 250_000 to -/// the recipient's `pool` address and the recipient sees it. -async fn send_to_pool(pool: e2e::Pool) { - let (test_manager, clients) = launch_and_fund_zcashd_faucet(1).await; - e2e::devtool::assert_send_to_pool(test_manager, clients, pool).await; -} - -/// zcashd analogue of devtool.rs's `shield_for_validator`: the recipient -/// receives a transparent send, then shields it into orchard (235_000 after the -/// ZIP-317 shielding fee). -async fn shield_for_validator() { - let (test_manager, clients) = launch_and_fund_zcashd_faucet(1).await; - // Pre-NU6.3 heights on zcashd: `shield` lands in orchard, not ironwood. - e2e::devtool::assert_shield_for_validator(test_manager, clients, e2e::Pool::Orchard).await; -} - -/// Devtool ports of `wallet_to_validator`'s `mod zcashd` send/shield/get-info -/// column. Deferred: the heavy finalization send (`sent_to::transparent`'s -/// seam-deep mine, waits on cheap filler mining), `sent_to::all`, and -/// `monitor_unverified_mempool` (unconfirmed mempool balances). -/// `send_to_transparent` here is the light send, matching the -/// zebrad devtool port. -mod wallet_to_validator { - use super::*; - - #[tokio::test(flavor = "multi_thread")] - async fn connect_to_node_get_info() { - let (mut test_manager, clients) = launch_zcashd_and_build_clients().await; - clients.get_info_faucet().await; - clients.get_info_recipient().await; - test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_orchard() { - send_to_pool(e2e::Pool::Orchard).await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_sapling() { - send_to_pool(e2e::Pool::Sapling).await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn send_to_transparent() { - send_to_pool(e2e::Pool::Transparent).await; - } - - #[tokio::test(flavor = "multi_thread")] - async fn shield() { - shield_for_validator().await; - } - - /// zcashd analogue of devtool.rs's gated `send_to_transparent_finalization`: - /// a transparent send returns the same address txids from the non-finalized - /// chain and after the seam-deep advance into the finalized DB. `#[ignore]`d - /// for the same reason — the advance mines orchard coinbase (~100 halo2 - /// proofs) until per-call cheap filler mining lands. - #[tokio::test(flavor = "multi_thread")] - #[cfg_attr( - not(feature = "devtool-incompatible"), - ignore = "heavy: seam-deep orchard advance (~100 halo2 proofs); un-ignore + transparent filler when cheap filler mining lands" - )] - async fn send_to_transparent_finalization() { - let (test_manager, clients) = launch_and_fund_zcashd_faucet(1).await; - e2e::devtool::assert_send_to_transparent_finalization(test_manager, clients).await; - } - - /// zcashd port of `sent_to::all` (heavy): one faucet funds a send to all - /// three pools, then a seam-deep advance, and each recipient pool reports - /// 250_000. `#[ignore]`d: the seam-deep advance mines orchard coinbase - /// (~100 halo2 proofs). The advance is faithful to the original but not - /// load-bearing for the per-pool balance asserts (the sends confirm in one - /// block), so this could be re-ported light (like the zebrad `send_to_all`) - /// instead of gated, if preferred. - #[tokio::test(flavor = "multi_thread")] - #[cfg_attr( - not(feature = "devtool-incompatible"), - ignore = "heavy: seam-deep orchard advance (~100 halo2 proofs); re-port light or un-ignore with transparent filler" - )] - async fn send_to_all() { - let (mut test_manager, mut clients) = launch_and_fund_zcashd_faucet(3).await; - - let recipient_ua = clients.get_recipient_address("unified").await; - let recipient_zaddr = clients.get_recipient_address("sapling").await; - let recipient_taddr = clients.get_recipient_address("transparent").await; - clients.send_from_faucet(&recipient_ua, 250_000).await; - clients.send_from_faucet(&recipient_zaddr, 250_000).await; - clients.send_from_faucet(&recipient_taddr, 250_000).await; - - test_manager - .generate_blocks_bulk_and_wait_for_tips( - // Advance past the seam so all three pool sends cross the finalised floor. - zaino_consensus::FAST_TEST_MAX_NONFINALISED_DEPTH + 5, - test_manager.subscriber(), - test_manager.subscriber(), - ) - .await; - clients.sync_recipient().await; - - let balance = clients.recipient_balance().await; - assert_eq!(e2e::Pool::Orchard.spendable_balance(&balance), 250_000); - assert_eq!(e2e::Pool::Sapling.spendable_balance(&balance), 250_000); - assert_eq!(e2e::Pool::Transparent.spendable_balance(&balance), 250_000); - - test_manager.close().await; - } -} diff --git a/live-tests/e2e/tests/ironwood_activation.rs b/live-tests/e2e/tests/ironwood_activation.rs index ec811533..4fab8671 100644 --- a/live-tests/e2e/tests/ironwood_activation.rs +++ b/live-tests/e2e/tests/ironwood_activation.rs @@ -1,25 +1,23 @@ //! Wallet-tier predicates across the Orchard→Ironwood activation boundary. //! -//! Every test here runs a devtool wallet on -//! [`ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS`] — the hermetic replay of what -//! The Public Testnet did once at height 4,134,000: heights 2 through 5 are -//! Orchard era, [`NU6_3_TRANSITION_BOUNDARY`] (6) onward is Ironwood era. -//! The wallets derive their activation schedule from the running validator -//! (`WalletNetwork::from_validator`, infrastructure ADR 0003), so the -//! fixture heights are typed in exactly one place: the zebrad launch -//! config. Height drift between wallet, indexer, and validator is -//! unrepresentable — zainod adopts the same schedule over -//! `getblockchaininfo` (zaino#1076). +//! Every test here runs a librustzcash wallet on a mid-chain NU6.3 schedule — +//! the hermetic replay of what The Public Testnet did once at height +//! 4,134,000: heights 2 through 5 are Orchard era, [`NU6_3_TRANSITION_BOUNDARY`] +//! (6) onward is Ironwood era. The schedule is pinned in exactly one place, the +//! `TestEnv` builder's [`activation_heights`], and the validator, indexer, and +//! wallet all adopt it (zainod over `getblockchaininfo`, the wallet over the +//! validator's activation heights); height drift between them is +//! unrepresentable. //! //! # The predicates, and where each era's cell is covered //! //! | predicate (wallet-observable) | Orchard era | Ironwood era | //! |----------------------------------------------|-------------|--------------| -//! | unified-address receipt lands in Orchard | here | false — `devtool.rs` `send_to_ironwood` asserts the Orchard pool stays empty | -//! | unified-address receipt lands in Ironwood | false (pool inactive) | `devtool.rs` `send_to_ironwood` | -//! | shielded-receiver coinbase pays the era pool | here (wallet view); wire tier in `compact_block_wire.rs` | `devtool.rs` `receives_mining_reward`; wire tier in `compact_block_wire.rs` | +//! | unified-address receipt lands in Orchard | here | false — `wallet_to_validator.rs` `send_to_unified` asserts the Orchard pool stays empty | +//! | unified-address receipt lands in Ironwood | false (pool inactive) | `wallet_to_validator.rs` `send_to_unified` | +//! | shielded-receiver coinbase pays the era pool | here (wallet view); wire tier in `compact_block_wire.rs` | `wallet_to_validator.rs` `receives_mining_reward`; wire tier in `compact_block_wire.rs` | //! | an Orchard note spends into an Ironwood receipt (ZIP 318 migration) | n/a (nothing to exit) | here | -//! | `shield` deposits into the era pool | here | `devtool.rs` `shield_for_validator` | +//! | `shield` deposits into the era pool | here | `wallet_to_validator.rs` `shield_for_validator` | //! | receipt pool flips between the last Orchard block and the activation block | here (boundary − 1) | here (exactly at the boundary) | //! | Orchard pool value grows only below the boundary; Ironwood holds value only from it | here (per-height value pools) | here | //! @@ -36,449 +34,838 @@ //! ), //! and we hold no pre-activation Orchard TAZ — so this hermetic fixture is //! the only controlled venue for it. -//! -//! Deferred cells the cross-address restriction implies (chain-walk tier, -//! not wallet tier): the Orchard pool value is non-increasing from the -//! boundary, and post-activation Orchard commitments exist only as -//! same-receiver change — note the Orchard note-commitment tree therefore -//! still grows after activation; do not encode a frozen-finalRoot predicate. -//! -//! Requires a `zcash-devtool` binary built with `--features regtest_support` -//! in `TEST_BINARIES_DIR`/`PATH`, alongside the usual validator binaries. - -use e2e::devtool::DevtoolClients; -use zaino_state::ZcashIndexer; -use zaino_testutils::{ - all_pools_i32, collect_block_range, PollableTip, TestManager, ValidatorKind, - NU6_3_TRANSITION_BOUNDARY, ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS, -}; -use zainodlib::error::IndexerError; -use zcash_local_net::validator::zebrad::Zebrad; - -/// Launch an orchard-receiver-mining zebrad + Zaino on the transition -/// heights, build devtool faucet/recipient wallets (their schedule derived -/// from the launched validator), mine one block, and sync the faucet. The -/// launch itself already leaves the tip at 2 (`TestManager` mines an -/// NU-activation block after block 1), so the helper returns at tip 3 with -/// the faucet holding the Orchard coinbase notes of heights 2 and 3. Tests -/// that need exact boundary positioning mine to absolute heights from the -/// observed tip rather than counting from here. The transition-fixture -/// analogue of `devtool.rs::launch_and_fund_faucet`. -async fn launch_transition_chain_and_fund_faucet( -) -> (TestManager, DevtoolClients) -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let test_manager = TestManager::::launch_mining_to( - zaino_testutils::SHIELDED_FUNDING_POOL, - &ValidatorKind::Zebrad, - None, - Some(ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS), - None, - true, - false, - false, - ) - .await - .expect("launch TestManager"); - - let mut clients = e2e::devtool::build_clients( - test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &test_manager.local_net, - ) - .await; - - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_faucet().await; - - (test_manager, clients) -} -/// The chain value (in zatoshis) of the pool named `pool_id` as of -/// `height`, read from the served verbosity-1 block object — the same -/// per-height `valuePools` a zcashd `getblock` reports. Verbosity 1, not 2: -/// the fetch backend cannot deserialize a verbosity-2 block object (its -/// `tx` entries are maps where the caller expects txid strings — a -/// `zaino-fetch` limitation originally, preserved here because the -/// verbosity-1 read is what this assertion wants anyway), and the value -/// pools ride along at verbosity 1. -async fn pool_zats_at_height(subscriber: &S, height: u32, pool_id: &str) -> i64 -where - S: ZcashIndexer, - IndexerError: From, -{ - let response = subscriber - .z_get_block(height.to_string(), Some(1)) - .await - .map_err(IndexerError::from) - .expect("z_get_block verbosity 1"); - let zebra_rpc::methods::GetBlock::Object(block) = response else { - panic!("verbosity-1 getblock must return a block object"); - }; - let pools = block - .value_pools() - .as_ref() - .expect("verbosity-1 block object carries value pools"); - pools - .iter() - .find(|pool| pool.id() == pool_id) - .unwrap_or_else(|| panic!("value pools must include {pool_id}")) - .chain_value_zat() - .zatoshis() -} +use std::time::Duration; -/// Orchard-era receipt: with the tip still below the boundary, the faucet's -/// coinbase note is an Orchard note (not Ironwood), and a unified-address -/// send received before the boundary lands in the recipient's Orchard pool -/// with the Ironwood pool exactly empty — the era-mirror of -/// `devtool.rs::send_to_pool(Ironwood)`. -async fn unified_receipt_lands_in_orchard_before_boundary() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, mut clients) = launch_transition_chain_and_fund_faucet::().await; - - // Tip is 3: inside the Orchard era, with room to confirm the send at - // height 4 while staying below the boundary at 6. - let faucet_balance = clients.faucet_balance().await; - assert!( - faucet_balance.orchard_spendable > 0, - "pre-boundary coinbase should be an orchard note, got {faucet_balance:?}" - ); - assert_eq!( - faucet_balance.ironwood_spendable, 0, - "no ironwood note can exist below the boundary, got {faucet_balance:?}" - ); - - let recipient = clients.get_recipient_address("unified").await; - let txid = clients.send_from_faucet(&recipient, 250_000).await; - dbg!(txid); - - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_recipient().await; - - let balance = clients.recipient_balance().await; - assert_eq!(e2e::Pool::Orchard.spendable_balance(&balance), 250_000); - assert_eq!(e2e::Pool::Ironwood.spendable_balance(&balance), 0); - - test_manager.close().await; -} - -/// The ZIP 318 migration shape: an Orchard note minted before the boundary -/// is spent after it, to a unified address generated after activation, and -/// the receipt lands in the Ironwood pool with the recipient's Orchard pool -/// exactly empty. The faucet's Orchard balance must shrink: from the -/// boundary, the cross-address restriction limits each Orchard action to -/// same-receiver change or withdrawal -/// (), -/// so a genuine Orchard spend nets sent-amount-plus-fee out of the pool even -/// when change returns to the spent note's address. -async fn orchard_note_spends_to_ironwood_across_boundary() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, mut clients) = launch_transition_chain_and_fund_faucet::().await; - - let pre_boundary_balance = clients.faucet_balance().await; - assert!( - pre_boundary_balance.orchard_spendable > 0, - "pre-boundary coinbase should be an orchard note, got {pre_boundary_balance:?}" - ); - - // Mine to the boundary itself, from the observed tip rather than a - // hand-count. The blocks below the boundary add more Orchard coinbase - // notes; the boundary block is the first Ironwood-era block, and its - // coinbase is the faucet's first Ironwood note. - let tip = u32::try_from(test_manager.subscriber().tip_height().await) - .expect("regtest tips fit in u32"); - test_manager - .generate_blocks_and_wait_for_tip( - NU6_3_TRANSITION_BOUNDARY - .checked_sub(tip) - .expect("the launch preamble must leave room below the boundary"), - test_manager.subscriber(), - ) - .await; - clients.sync_faucet().await; - let crossed_balance = clients.faucet_balance().await; - let orchard_before_send = crossed_balance.orchard_spendable; - assert!( - crossed_balance.ironwood_spendable > 0, - "the boundary coinbase should be an ironwood note, got {crossed_balance:?}" - ); - - // Generated only now — after activation — per the migration shape under - // test: old-pool note, new-era address. - let recipient = clients.get_recipient_address("unified").await; - let txid = clients.send_from_faucet(&recipient, 250_000).await; - dbg!(txid); - - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_faucet().await; - clients.sync_recipient().await; - - let balance = clients.recipient_balance().await; - assert_eq!(e2e::Pool::Ironwood.spendable_balance(&balance), 250_000); - assert_eq!(e2e::Pool::Orchard.spendable_balance(&balance), 0); - - // Pins that the send actually exited the Orchard pool rather than - // spending the boundary-height Ironwood coinbase — the note-selection - // question the first live runs of this suite exist to answer. - assert!( - clients.faucet_balance().await.orchard_spendable < orchard_before_send, - "the migration send must spend an orchard note" - ); - - // Chain-tier consequences, read from the served per-height value pools. - // The Ironwood pool holds no value below the activation height; the - // Orchard pool grows only below it (its coinbases), holds exactly - // steady across the boundary edge (the activation block's coinbase pays - // Ironwood, and no new value may enter Orchard), and shrinks at the - // migration block by the withdrawn amount plus fee. - let boundary = NU6_3_TRANSITION_BOUNDARY; - let migration_height = boundary + 1; - let subscriber = test_manager.subscriber(); - let mut orchard_at = Vec::new(); - for height in 1..=migration_height { - let ironwood = pool_zats_at_height(subscriber, height, "ironwood").await; - let orchard = pool_zats_at_height(subscriber, height, "orchard").await; - if height < boundary { - assert_eq!( - ironwood, 0, - "the ironwood pool must hold no value at height {height}, below the boundary" - ); - } - orchard_at.push(orchard); - } - for (index, orchard) in orchard_at.iter().enumerate() { - let height = index + 1; - let ironwood = pool_zats_at_height(subscriber, height as u32, "ironwood").await; - eprintln!("pool values at height {height}: orchard={orchard} ironwood={ironwood}"); - } - let orchard = |height: u32| orchard_at[height as usize - 1]; - for height in 2..boundary { - assert!( - orchard(height) > orchard(height - 1), - "each pre-boundary coinbase must grow the orchard pool (height {height}: {} after {})", - orchard(height), - orchard(height - 1) - ); - } - assert_eq!( - orchard(boundary), - orchard(boundary - 1), - "the orchard pool must hold exactly steady across the boundary edge" - ); - assert!( - pool_zats_at_height(subscriber, boundary, "ironwood").await > 0, - "the activation block's coinbase must give the ironwood pool its first value" - ); - assert!( - orchard(migration_height) < orchard(boundary), - "the migration block must shrink the orchard pool ({} at the boundary, {} at the migration block)", - orchard(boundary), - orchard(migration_height) - ); - - test_manager.close().await; -} +use anyhow::{Context, Result}; +use ztest::prelude::*; -/// The receipt pool flips between adjacent blocks: a send confirmed in the -/// last Orchard-era block (boundary − 1) lands in Orchard, and a send built -/// one block below the boundary but confirmed in the activation block -/// itself lands in Ironwood. The second send also pins the wallet's era -/// anticipation at the edge: it is constructed while the tip is still -/// Orchard-era, targeting the first Ironwood-era height, and it spends an -/// Orchard note (the faucet holds no Ironwood note until the activation -/// block is mined) — a migration transaction in the activation block. -async fn receipts_flip_pools_exactly_at_the_boundary() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, mut clients) = launch_transition_chain_and_fund_faucet::().await; - - // Position the tip at exactly boundary − 2, from the observed tip - // rather than a hand-count, so the two sends below confirm at exactly - // boundary − 1 and the boundary. - let tip = u32::try_from(test_manager.subscriber().tip_height().await) - .expect("regtest tips fit in u32"); - test_manager - .generate_blocks_and_wait_for_tip( - NU6_3_TRANSITION_BOUNDARY - .checked_sub(2 + tip) - .expect("the launch preamble must leave room below the boundary"), - test_manager.subscriber(), - ) - .await; - clients.sync_faucet().await; - - let recipient = clients.get_recipient_address("unified").await; - - // Confirmed in block 5: the last Orchard-era block. - let last_orchard_txid = clients.send_from_faucet(&recipient, 250_000).await; - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_faucet().await; - clients.sync_recipient().await; - let balance = clients.recipient_balance().await; - assert_eq!( - e2e::Pool::Orchard.spendable_balance(&balance), - 250_000, - "receipt confirmed at boundary - 1 must be orchard, got {balance:?}" - ); - assert_eq!( - e2e::Pool::Ironwood.spendable_balance(&balance), - 0, - "no ironwood receipt below the boundary, got {balance:?}" - ); - - // Built at tip boundary − 1, confirmed in block 6: the activation block. - let first_ironwood_txid = clients.send_from_faucet(&recipient, 250_000).await; - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_recipient().await; - let balance = clients.recipient_balance().await; - assert_eq!(e2e::Pool::Ironwood.spendable_balance(&balance), 250_000); - assert_eq!( - e2e::Pool::Orchard.spendable_balance(&balance), - 250_000, - "the pre-boundary receipt must survive the flip unchanged" - ); - - // Era composition of the two served edge blocks. - let subscriber = test_manager.subscriber(); - let boundary = u64::from(NU6_3_TRANSITION_BOUNDARY); - let blocks = collect_block_range(subscriber, boundary - 1, boundary, all_pools_i32()).await; - let [last_orchard_block, activation_block] = blocks.as_slice() else { - panic!("expected exactly the two edge blocks, got {}", blocks.len()); - }; - assert_eq!(last_orchard_block.height, boundary - 1); - assert_eq!(activation_block.height, boundary); - let last_orchard_txid = e2e::devtool::txid_from_devtool(&last_orchard_txid); - e2e::assert_pool_present(last_orchard_block, &last_orchard_txid, e2e::Pool::Orchard); - e2e::assert_pool_absent(last_orchard_block, &last_orchard_txid, e2e::Pool::Ironwood); - let first_ironwood_txid = e2e::devtool::txid_from_devtool(&first_ironwood_txid); - e2e::assert_pool_present(activation_block, &first_ironwood_txid, e2e::Pool::Ironwood); - // The second send is itself migration-shaped: built one block below the - // boundary, it spends an Orchard note (the faucet's only spendable kind - // at that tip), so its compact form carries the Orchard spend's data - // alongside the Ironwood receipt. The receipt's pool routing is what - // flips at the boundary, and that is asserted at the wallet tier above. - e2e::assert_pool_present(activation_block, &first_ironwood_txid, e2e::Pool::Orchard); - - test_manager.close().await; -} +use e2e::{assert_pool_absent, assert_pool_present, Pool}; -/// Below the boundary, `shield` deposits into the Orchard pool: the faucet -/// funds the recipient's transparent address, the recipient shields, and -/// the shielded balance (net of the ZIP-317 fee, mirroring -/// `devtool.rs::shield_for_validator`) lands in Orchard with the Ironwood -/// pool exactly empty — the era-mirror of the Ironwood-era shield cell. -async fn shield_deposits_to_orchard_before_boundary() -where - Conn: zaino_testutils::ValidatorConnectionMarker, -{ - let (mut test_manager, mut clients) = launch_transition_chain_and_fund_faucet::().await; - - // Tip is 3; the transparent receipt confirms at 4 and the shield at 5, - // all below the boundary at 6. - let recipient_t = clients.get_recipient_address("transparent").await; - clients.send_from_faucet(&recipient_t, 250_000).await; - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_recipient().await; - assert_eq!( - e2e::Pool::Transparent.spendable_balance(&clients.recipient_balance().await), - 250_000 - ); - - clients.shield_recipient().await; - test_manager - .generate_blocks_and_wait_for_tip(1, test_manager.subscriber()) - .await; - clients.sync_recipient().await; - - let balance = clients.recipient_balance().await; - assert_eq!(e2e::Pool::Orchard.spendable_balance(&balance), 235_000); - assert_eq!(e2e::Pool::Ironwood.spendable_balance(&balance), 0); - - test_manager.close().await; -} +/// Indexer sync / pod-ready timeout. +const READY: Duration = Duration::from_secs(120); +/// Standard transfer amount (zatoshis). +const SEND_AMOUNT: u64 = 250_000; +/// zingolib's ZIP-317 fee for a single-note shield round under regtest. +const SHIELD_FEE: u64 = 15_000; +/// The mid-chain NU6.3 (Ironwood) activation height: heights `[2, 6)` are +/// Orchard era, height 6 onward is Ironwood era. +const NU6_3_TRANSITION_BOUNDARY: u32 = 6; mod zebrad { - mod fetch_service { - use zaino_testutils::Rpc; + use super::*; - /// multi_thread required: the test manager spawns the validator and - /// indexer services. + mod fetch_service { + use super::*; + + /// Orchard-era receipt: with the tip still below the boundary, the + /// faucet's coinbase note is an Orchard note (not Ironwood), and a + /// unified-address send received before the boundary lands in the + /// recipient's Orchard pool with the Ironwood pool exactly empty — the + /// era-mirror of `wallet_to_validator.rs::send_to_unified`. + #[ztest::qos::wallet] #[tokio::test(flavor = "multi_thread")] - async fn unified_receipt_lands_in_orchard_before_boundary() { - crate::unified_receipt_lands_in_orchard_before_boundary::().await; + async fn unified_receipt_lands_in_orchard_before_boundary() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(), + ); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Orchard.ztest()), + ); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + // Funding warms up to NU5 then mines one Orchard-era block (tip 2), + // well below the boundary, so the faucet holds an Orchard note. + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let faucet_balance = faucet.balances().await?; + assert!( + faucet_balance.get(Pool::Orchard.ztest()) > 0, + "pre-boundary coinbase should be an orchard note, got {faucet_balance:?}" + ); + assert_eq!( + faucet_balance.get(Pool::Ironwood.ztest()), + 0, + "no ironwood note can exist below the boundary, got {faucet_balance:?}" + ); + + let recipient = wallet.recipient(&validator, &indexer).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + faucet.send(&ua, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + + let balance = recipient.balances().await?; + assert_eq!(balance.get(Pool::Orchard.ztest()), SEND_AMOUNT); + assert_eq!(balance.get(Pool::Ironwood.ztest()), 0); + Ok(()) } - /// multi_thread required: the test manager spawns the validator and - /// indexer services. + /// The ZIP 318 migration shape: an Orchard note minted before the + /// boundary is spent after it, to a unified address, and the receipt + /// lands in the Ironwood pool with the recipient's Orchard pool exactly + /// empty. The faucet's Orchard balance must shrink: from the boundary + /// the cross-address restriction limits each Orchard action to + /// same-receiver change or withdrawal, so a genuine Orchard spend nets + /// sent-amount-plus-fee out of the pool. The served per-height + /// `valuePools` totals witness the Orchard pool growing only below the + /// boundary and the Ironwood pool taking value only from it. + #[ztest::qos::wallet] #[tokio::test(flavor = "multi_thread")] - async fn orchard_note_spends_to_ironwood_across_boundary() { - crate::orchard_note_spends_to_ironwood_across_boundary::().await; + async fn orchard_note_spends_to_ironwood_across_boundary() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(), + ); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Orchard.ztest()), + ); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let boundary = NU6_3_TRANSITION_BOUNDARY as usize; + let migration_height = boundary + 1; + // Per-height served value-pool totals, indexed by height; slots 0 + // and 1 are pre-NU5 and never read. + let mut orchard = vec![0u64; migration_height + 1]; + let mut ironwood = vec![0u64; migration_height + 1]; + // A missing pool or field is a failure, never a zero: the + // below-boundary ironwood assertions would pass vacuously. + let pool_zats = |info: &serde_json::Value, pool_id: &str| -> Result { + info.get("valuePools") + .and_then(serde_json::Value::as_array) + .context("getblockchaininfo.valuePools")? + .iter() + .find(|pool| { + pool.get("id").and_then(serde_json::Value::as_str) == Some(pool_id) + }) + .with_context(|| format!("valuePools has no {pool_id} entry"))? + .get("chainValueZat") + .and_then(serde_json::Value::as_u64) + .with_context(|| format!("valuePools[{pool_id}].chainValueZat")) + }; + + // Fund one Orchard note below the boundary (tip 2), then snapshot the + // funded tip's served value pools. + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let irpc = indexer.json_rpc().await?; + let info = irpc + .call_value("getblockchaininfo", serde_json::json!([])) + .await?; + orchard[2] = pool_zats(&info, "orchard")?; + ironwood[2] = pool_zats(&info, "ironwood")?; + + let pre_boundary_balance = faucet.balances().await?; + assert!( + pre_boundary_balance.get(Pool::Orchard.ztest()) > 0, + "pre-boundary coinbase should be an orchard note, got {pre_boundary_balance:?}" + ); + + // Mine to the boundary one block at a time, recording each height's + // served value pools. Each pre-boundary coinbase grows Orchard; the + // activation block's coinbase is the chain's first Ironwood value. + for height in 3..=boundary { + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + let info = irpc + .call_value("getblockchaininfo", serde_json::json!([])) + .await?; + orchard[height] = pool_zats(&info, "orchard")?; + ironwood[height] = pool_zats(&info, "ironwood")?; + } + + faucet.sync().await?; + let crossed_balance = faucet.balances().await?; + let orchard_before_send = crossed_balance.get(Pool::Orchard.ztest()); + assert!( + crossed_balance.get(Pool::Ironwood.ztest()) > 0, + "the boundary coinbase should be an ironwood note, got {crossed_balance:?}" + ); + + // The migration send: built at the boundary tip, it spends an + // Orchard note into a unified-address (Ironwood) receipt. + let recipient = wallet.recipient(&validator, &indexer).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + faucet + .send_from(&[Pool::Orchard.ztest()], &ua, SEND_AMOUNT) + .await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + let info = irpc + .call_value("getblockchaininfo", serde_json::json!([])) + .await?; + orchard[migration_height] = pool_zats(&info, "orchard")?; + ironwood[migration_height] = pool_zats(&info, "ironwood")?; + faucet.sync().await?; + recipient.sync().await?; + + let balance = recipient.balances().await?; + assert_eq!(balance.get(Pool::Ironwood.ztest()), SEND_AMOUNT); + assert_eq!(balance.get(Pool::Orchard.ztest()), 0); + assert!( + faucet.balances().await?.get(Pool::Orchard.ztest()) < orchard_before_send, + "the migration send must spend an orchard note" + ); + + // Per-height served value-pool assertions, over the heights actually read. + for (height, &value) in ironwood.iter().enumerate().take(boundary).skip(2) { + assert_eq!( + value, 0, + "the ironwood pool must hold no value at height {height}, below the boundary" + ); + } + assert!( + orchard[2] > 0, + "the first pre-boundary coinbase must give the orchard pool value" + ); + for height in 3..boundary { + assert!( + orchard[height] > orchard[height - 1], + "each pre-boundary coinbase must grow the orchard pool (height {height}: {} after {})", + orchard[height], + orchard[height - 1] + ); + } + assert_eq!( + orchard[boundary], + orchard[boundary - 1], + "the orchard pool must hold exactly steady across the boundary edge" + ); + assert!( + ironwood[boundary] > 0, + "the activation block's coinbase must give the ironwood pool its first value" + ); + assert!( + orchard[migration_height] < orchard[boundary], + "the migration block must shrink the orchard pool ({} at the boundary, {} at the migration block)", + orchard[boundary], + orchard[migration_height] + ); + Ok(()) } - /// multi_thread required: the test manager spawns the validator and - /// indexer services. + /// The receipt pool flips between adjacent blocks: a send confirmed in + /// the last Orchard-era block (boundary − 1) lands in Orchard, and a send + /// built one block below the boundary but confirmed in the activation + /// block itself lands in Ironwood. The second send is itself + /// migration-shaped: constructed while the tip is still Orchard-era, it + /// spends an Orchard note (the faucet's only spendable kind at that tip), + /// so the activation block carries the Orchard spend's data alongside the + /// Ironwood receipt. + #[ztest::qos::wallet] #[tokio::test(flavor = "multi_thread")] - async fn receipts_flip_pools_exactly_at_the_boundary() { - crate::receipts_flip_pools_exactly_at_the_boundary::().await; + async fn receipts_flip_pools_exactly_at_the_boundary() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(), + ); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Orchard.ztest()), + ); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + + // Position the tip at boundary − 2, so the first send (built here) + // confirms in the last Orchard-era block (boundary − 1). + let cur = u32::from(validator.chain_height().await?); + let target = NU6_3_TRANSITION_BOUNDARY - 2; + if cur < target { + let tip = validator.generate_blocks(target - cur).await?; + indexer.wait_for_block_num(tip, READY).await?; + faucet.sync().await?; + } + + let recipient = wallet.recipient(&validator, &indexer).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + let last_orchard_txid = faucet + .send(&ua, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; // boundary − 1 + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + + let balance = recipient.balances().await?; + assert_eq!( + balance.get(Pool::Orchard.ztest()), + SEND_AMOUNT, + "receipt confirmed at boundary - 1 must be orchard, got {balance:?}" + ); + assert_eq!( + balance.get(Pool::Ironwood.ztest()), + 0, + "no ironwood receipt below the boundary, got {balance:?}" + ); + + // The second send is built at boundary − 1 (still Orchard era, + // spending an Orchard note) but confirmed in the activation block. + faucet.sync().await?; + let first_ironwood_txid = faucet + .send(&ua, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; // boundary + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + + let balance = recipient.balances().await?; + assert_eq!(balance.get(Pool::Ironwood.ztest()), SEND_AMOUNT); + assert_eq!( + balance.get(Pool::Orchard.ztest()), + SEND_AMOUNT, + "the pre-boundary receipt must survive the flip unchanged" + ); + + // Served edge-block pool-presence: the last Orchard block and the + // activation block. + let blocks = indexer + .get_block_range(BlockHeight::from(1u32), tip) + .await?; + let last_orchard_block = blocks + .iter() + .find(|b| b.height == u64::from(NU6_3_TRANSITION_BOUNDARY - 1)) + .expect("boundary-1 block served"); + let activation_block = blocks + .iter() + .find(|b| b.height == u64::from(NU6_3_TRANSITION_BOUNDARY)) + .expect("boundary block served"); + assert_eq!( + last_orchard_block.height, + u64::from(NU6_3_TRANSITION_BOUNDARY - 1) + ); + assert_eq!( + activation_block.height, + u64::from(NU6_3_TRANSITION_BOUNDARY) + ); + assert_pool_present(last_orchard_block, &last_orchard_txid, Pool::Orchard); + assert_pool_absent(last_orchard_block, &last_orchard_txid, Pool::Ironwood); + assert_pool_present(activation_block, &first_ironwood_txid, Pool::Ironwood); + // The second send is migration-shaped: it spends an Orchard note, so + // its compact form carries the Orchard spend's data too. + assert_pool_present(activation_block, &first_ironwood_txid, Pool::Orchard); + Ok(()) } - /// multi_thread required: the test manager spawns the validator and - /// indexer services. + /// Below the boundary, `shield` deposits into the Orchard pool: the + /// faucet funds the recipient's transparent address, the recipient + /// shields, and the shielded balance (net of the ZIP-317 fee, mirroring + /// `wallet_to_validator.rs::shield_for_validator`) lands in Orchard with the Ironwood + /// pool exactly empty — the era-mirror of the Ironwood-era shield cell. + #[ztest::qos::wallet] #[tokio::test(flavor = "multi_thread")] - async fn shield_deposits_to_orchard_before_boundary() { - crate::shield_deposits_to_orchard_before_boundary::().await; + async fn shield_deposits_to_orchard_before_boundary() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(), + ); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Orchard.ztest()), + ); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&taddr, SEND_AMOUNT).await?; + + // Confirm the transparent receipt below the boundary (height 4). + let cur = u32::from(validator.chain_height().await?); + let target = NU6_3_TRANSITION_BOUNDARY - 2; + let tip = validator + .generate_blocks(target.saturating_sub(cur).max(1)) + .await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + assert_eq!( + recipient.balances().await?.get(Pool::Transparent.ztest()), + SEND_AMOUNT + ); + + // The shield is built below the boundary, so it deposits to Orchard. + recipient.shield().await?; + let tip = validator.generate_blocks(1).await?; // height 5, still Orchard era + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + + let balance = recipient.balances().await?; + assert_eq!( + balance.get(Pool::Orchard.ztest()), + SEND_AMOUNT - SHIELD_FEE, + "shielded balance must be the send net of the ZIP-317 fee \ + (below NU6.3 the shield deposits into the Orchard pool)" + ); + assert_eq!(balance.get(Pool::Ironwood.ztest()), 0); + Ok(()) } } mod state_service { - use zaino_testutils::Direct; + use super::*; - /// multi_thread required: the test manager spawns the validator and - /// indexer services. + /// State-backend port of + /// [`super::fetch_service::unified_receipt_lands_in_orchard_before_boundary`]. + #[ztest::qos::wallet] #[tokio::test(flavor = "multi_thread")] - async fn unified_receipt_lands_in_orchard_before_boundary() { - crate::unified_receipt_lands_in_orchard_before_boundary::().await; + async fn unified_receipt_lands_in_orchard_before_boundary() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(), + ); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Orchard.ztest()) + .mount(&vol), + ); + let indexer = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let faucet_balance = faucet.balances().await?; + assert!( + faucet_balance.get(Pool::Orchard.ztest()) > 0, + "pre-boundary coinbase should be an orchard note, got {faucet_balance:?}" + ); + assert_eq!( + faucet_balance.get(Pool::Ironwood.ztest()), + 0, + "no ironwood note can exist below the boundary, got {faucet_balance:?}" + ); + + let recipient = wallet.recipient(&validator, &indexer).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + faucet.send(&ua, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + + let balance = recipient.balances().await?; + assert_eq!(balance.get(Pool::Orchard.ztest()), SEND_AMOUNT); + assert_eq!(balance.get(Pool::Ironwood.ztest()), 0); + Ok(()) } - /// multi_thread required: the test manager spawns the validator and - /// indexer services. + /// State-backend port of + /// [`super::fetch_service::orchard_note_spends_to_ironwood_across_boundary`]. + #[ztest::qos::wallet] #[tokio::test(flavor = "multi_thread")] - async fn orchard_note_spends_to_ironwood_across_boundary() { - crate::orchard_note_spends_to_ironwood_across_boundary::().await; + async fn orchard_note_spends_to_ironwood_across_boundary() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(), + ); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Orchard.ztest()) + .mount(&vol), + ); + let indexer = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let boundary = NU6_3_TRANSITION_BOUNDARY as usize; + let migration_height = boundary + 1; + let mut orchard = vec![0u64; migration_height + 1]; + let mut ironwood = vec![0u64; migration_height + 1]; + // A missing pool or field is a failure, never a zero: the + // below-boundary ironwood assertions would pass vacuously. + let pool_zats = |info: &serde_json::Value, pool_id: &str| -> Result { + info.get("valuePools") + .and_then(serde_json::Value::as_array) + .context("getblockchaininfo.valuePools")? + .iter() + .find(|pool| { + pool.get("id").and_then(serde_json::Value::as_str) == Some(pool_id) + }) + .with_context(|| format!("valuePools has no {pool_id} entry"))? + .get("chainValueZat") + .and_then(serde_json::Value::as_u64) + .with_context(|| format!("valuePools[{pool_id}].chainValueZat")) + }; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let irpc = indexer.json_rpc().await?; + let info = irpc + .call_value("getblockchaininfo", serde_json::json!([])) + .await?; + orchard[2] = pool_zats(&info, "orchard")?; + ironwood[2] = pool_zats(&info, "ironwood")?; + + let pre_boundary_balance = faucet.balances().await?; + assert!( + pre_boundary_balance.get(Pool::Orchard.ztest()) > 0, + "pre-boundary coinbase should be an orchard note, got {pre_boundary_balance:?}" + ); + + for height in 3..=boundary { + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + let info = irpc + .call_value("getblockchaininfo", serde_json::json!([])) + .await?; + orchard[height] = pool_zats(&info, "orchard")?; + ironwood[height] = pool_zats(&info, "ironwood")?; + } + + faucet.sync().await?; + let crossed_balance = faucet.balances().await?; + let orchard_before_send = crossed_balance.get(Pool::Orchard.ztest()); + assert!( + crossed_balance.get(Pool::Ironwood.ztest()) > 0, + "the boundary coinbase should be an ironwood note, got {crossed_balance:?}" + ); + + let recipient = wallet.recipient(&validator, &indexer).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + faucet + .send_from(&[Pool::Orchard.ztest()], &ua, SEND_AMOUNT) + .await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + let info = irpc + .call_value("getblockchaininfo", serde_json::json!([])) + .await?; + orchard[migration_height] = pool_zats(&info, "orchard")?; + ironwood[migration_height] = pool_zats(&info, "ironwood")?; + faucet.sync().await?; + recipient.sync().await?; + + let balance = recipient.balances().await?; + assert_eq!(balance.get(Pool::Ironwood.ztest()), SEND_AMOUNT); + assert_eq!(balance.get(Pool::Orchard.ztest()), 0); + assert!( + faucet.balances().await?.get(Pool::Orchard.ztest()) < orchard_before_send, + "the migration send must spend an orchard note" + ); + + // Per-height served value-pool assertions, over the heights actually read. + for (height, &value) in ironwood.iter().enumerate().take(boundary).skip(2) { + assert_eq!( + value, 0, + "the ironwood pool must hold no value at height {height}, below the boundary" + ); + } + assert!( + orchard[2] > 0, + "the first pre-boundary coinbase must give the orchard pool value" + ); + for height in 3..boundary { + assert!( + orchard[height] > orchard[height - 1], + "each pre-boundary coinbase must grow the orchard pool (height {height}: {} after {})", + orchard[height], + orchard[height - 1] + ); + } + assert_eq!( + orchard[boundary], + orchard[boundary - 1], + "the orchard pool must hold exactly steady across the boundary edge" + ); + assert!( + ironwood[boundary] > 0, + "the activation block's coinbase must give the ironwood pool its first value" + ); + assert!( + orchard[migration_height] < orchard[boundary], + "the migration block must shrink the orchard pool ({} at the boundary, {} at the migration block)", + orchard[boundary], + orchard[migration_height] + ); + Ok(()) } - /// multi_thread required: the test manager spawns the validator and - /// indexer services. + /// State-backend port of + /// [`super::fetch_service::receipts_flip_pools_exactly_at_the_boundary`]. + #[ztest::qos::wallet] #[tokio::test(flavor = "multi_thread")] - async fn receipts_flip_pools_exactly_at_the_boundary() { - crate::receipts_flip_pools_exactly_at_the_boundary::().await; + async fn receipts_flip_pools_exactly_at_the_boundary() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(), + ); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Orchard.ztest()) + .mount(&vol), + ); + let indexer = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + + let cur = u32::from(validator.chain_height().await?); + let target = NU6_3_TRANSITION_BOUNDARY - 2; + if cur < target { + let tip = validator.generate_blocks(target - cur).await?; + indexer.wait_for_block_num(tip, READY).await?; + faucet.sync().await?; + } + + let recipient = wallet.recipient(&validator, &indexer).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + let last_orchard_txid = faucet + .send(&ua, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; // boundary − 1 + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + + let balance = recipient.balances().await?; + assert_eq!( + balance.get(Pool::Orchard.ztest()), + SEND_AMOUNT, + "receipt confirmed at boundary - 1 must be orchard, got {balance:?}" + ); + assert_eq!( + balance.get(Pool::Ironwood.ztest()), + 0, + "no ironwood receipt below the boundary, got {balance:?}" + ); + + faucet.sync().await?; + let first_ironwood_txid = faucet + .send(&ua, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; // boundary + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + + let balance = recipient.balances().await?; + assert_eq!(balance.get(Pool::Ironwood.ztest()), SEND_AMOUNT); + assert_eq!( + balance.get(Pool::Orchard.ztest()), + SEND_AMOUNT, + "the pre-boundary receipt must survive the flip unchanged" + ); + + let blocks = indexer + .get_block_range(BlockHeight::from(1u32), tip) + .await?; + let last_orchard_block = blocks + .iter() + .find(|b| b.height == u64::from(NU6_3_TRANSITION_BOUNDARY - 1)) + .expect("boundary-1 block served"); + let activation_block = blocks + .iter() + .find(|b| b.height == u64::from(NU6_3_TRANSITION_BOUNDARY)) + .expect("boundary block served"); + assert_eq!( + last_orchard_block.height, + u64::from(NU6_3_TRANSITION_BOUNDARY - 1) + ); + assert_eq!( + activation_block.height, + u64::from(NU6_3_TRANSITION_BOUNDARY) + ); + assert_pool_present(last_orchard_block, &last_orchard_txid, Pool::Orchard); + assert_pool_absent(last_orchard_block, &last_orchard_txid, Pool::Ironwood); + assert_pool_present(activation_block, &first_ironwood_txid, Pool::Ironwood); + assert_pool_present(activation_block, &first_ironwood_txid, Pool::Orchard); + Ok(()) } - /// multi_thread required: the test manager spawns the validator and - /// indexer services. + /// State-backend port of + /// [`super::fetch_service::shield_deposits_to_orchard_before_boundary`]. + #[ztest::qos::wallet] #[tokio::test(flavor = "multi_thread")] - async fn shield_deposits_to_orchard_before_boundary() { - crate::shield_deposits_to_orchard_before_boundary::().await; + async fn shield_deposits_to_orchard_before_boundary() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY).activation_heights( + ActivationHeights::builder() + .set_overwinter(Some(1)) + .set_sapling(Some(1)) + .set_blossom(Some(1)) + .set_heartwood(Some(1)) + .set_canopy(Some(1)) + .set_nu5(Some(2)) + .set_nu6(Some(2)) + .set_nu6_1(Some(2)) + .set_nu6_2(Some(2)) + .set_nu6_3(Some(NU6_3_TRANSITION_BOUNDARY)) + .build(), + ); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Orchard.ztest()) + .mount(&vol), + ); + let indexer = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&taddr, SEND_AMOUNT).await?; + + let cur = u32::from(validator.chain_height().await?); + let target = NU6_3_TRANSITION_BOUNDARY - 2; + let tip = validator + .generate_blocks(target.saturating_sub(cur).max(1)) + .await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + assert_eq!( + recipient.balances().await?.get(Pool::Transparent.ztest()), + SEND_AMOUNT + ); + + recipient.shield().await?; + let tip = validator.generate_blocks(1).await?; // height 5, still Orchard era + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + + let balance = recipient.balances().await?; + assert_eq!( + balance.get(Pool::Orchard.ztest()), + SEND_AMOUNT - SHIELD_FEE, + "shielded balance must be the send net of the ZIP-317 fee \ + (below NU6.3 the shield deposits into the Orchard pool)" + ); + assert_eq!(balance.get(Pool::Ironwood.ztest()), 0); + Ok(()) } } } diff --git a/live-tests/e2e/tests/test_vectors.rs b/live-tests/e2e/tests/test_vectors.rs deleted file mode 100644 index 09b5090d..00000000 --- a/live-tests/e2e/tests/test_vectors.rs +++ /dev/null @@ -1,704 +0,0 @@ -//! Holds code used to build test vector data for unit tests. These tests should not be run by default or in CI. - -use corez::io::{self, Read, Write}; -use futures::TryFutureExt as _; -use std::fs; -use std::fs::File; -use std::io::BufReader; -use std::io::BufWriter; -use std::path::Path; -use std::sync::Arc; -use tower::{Service, ServiceExt as _}; -use zaino_state::read_u32_le; -use zaino_state::read_u64_le; -use zaino_state::write_u32_le; -use zaino_state::write_u64_le; -use zaino_state::ChainWork; -use zaino_state::CompactSize; -use zaino_state::ZcashIndexer; -use zaino_testutils::{Direct, TestManager, ValidatorKind}; -use zcash_local_net::validator::zebrad::Zebrad; -use zebra_chain::serialization::{ZcashDeserialize, ZcashSerialize}; -use zebra_rpc::methods::GetAddressUtxos; -use zebra_rpc::methods::{GetAddressBalanceRequest, GetAddressTxIdsRequest, GetBlockTransaction}; -use zebra_state::HashOrHeight; -use zebra_state::{ReadRequest, ReadResponse}; - -macro_rules! expected_read_response { - ($response:ident, $expected_variant:ident) => { - match $response { - ReadResponse::$expected_variant(inner) => inner, - unexpected => { - unreachable!("Unexpected response from state service: {unexpected:?}") - } - } - }; -} - -#[tokio::test(flavor = "multi_thread")] -#[cfg_attr( - not(feature = "devtool-incompatible"), - ignore = "Not a test: builds test-vector data for zaino_state::chain_index unit tests. Also funds via transparent-coinbase shielding — un-ignore to regenerate vectors once devtool can shield its own transparent coinbase (tracked by tests/devtool.rs's address_deltas)." -)] -#[allow(deprecated)] -async fn create_200_block_regtest_chain_vectors() { - // The committed unit-test vectors encode a mixed-pool chain built by - // repeatedly shielding transparent coinbase — mining must stay transparent - // so regenerated vectors keep that shape. - let mut test_manager = TestManager::::launch_mining_to( - zaino_testutils::MinerPool::Transparent, - &ValidatorKind::Zebrad, - None, - None, - None, - true, - false, - false, - ) - .await - .unwrap(); - - let state_service_subscriber = test_manager.service_subscriber.take().unwrap(); - - let mut clients = e2e::devtool::build_clients( - test_manager - .zaino_grpc_listen_address - .expect("zaino enabled") - .port(), - &test_manager.local_net, - ) - .await; - - let faucet_taddr = clients.get_faucet_address("transparent").await; - let faucet_saddr = clients.get_faucet_address("sapling").await; - let faucet_uaddr = clients.get_faucet_address("unified").await; - - let recipient_taddr = clients.get_recipient_address("transparent").await; - let recipient_saddr = clients.get_recipient_address("sapling").await; - let recipient_uaddr = clients.get_recipient_address("unified").await; - - // *** Mine past coinbase maturity, shield the first reward, and mine it in *** - // Mature the faucet's transparent coinbase (100-block maturity) and shield - // it. Devtool analogue of `shield_faucet_rounds`; requires the devtool wallet - // to spend its own transparent coinbase (see #[ignore]). Mine - // generously (150) so the earliest coinbase — a few blocks past genesis — is - // comfortably mature before the shield, regardless of startup height. - test_manager - .generate_blocks_and_wait_for_tip(150, &state_service_subscriber) - .await; - clients.sync_faucet().await; - clients.shield_faucet().await; - test_manager - .generate_blocks_and_wait_for_tip(1, &state_service_subscriber) - .await; - - // *** Build 100 block chain holding transparent, sapling, and orchard transactions *** - // create transactions - clients.shield_faucet().await; - clients - .send_from_faucet(recipient_uaddr.as_str(), 250_000) - .await; - - // Generate block - test_manager - .generate_blocks_and_wait_for_tip(1, &state_service_subscriber) - .await; - - // sync wallets - clients.sync_faucet().await; - clients.sync_recipient().await; - - // create transactions - clients.shield_faucet().await; - - clients - .send_from_faucet(recipient_taddr.as_str(), 250_000) - .await; - clients - .send_from_faucet(recipient_uaddr.as_str(), 250_000) - .await; - - clients - .send_from_recipient(faucet_taddr.as_str(), 200_000) - .await; - - // Generate block - test_manager - .generate_blocks_and_wait_for_tip(1, &state_service_subscriber) - .await; - - // sync wallets - clients.sync_faucet().await; - clients.sync_recipient().await; - - // create transactions - clients.shield_faucet().await; - clients.shield_recipient().await; - - clients - .send_from_faucet(recipient_taddr.as_str(), 250_000) - .await; - clients - .send_from_faucet(recipient_uaddr.as_str(), 250_000) - .await; - - clients - .send_from_recipient(faucet_taddr.as_str(), 250_000) - .await; - - // Generate block - test_manager - .generate_blocks_and_wait_for_tip(1, &state_service_subscriber) - .await; - - for _i in 0..48 { - // sync wallets - clients.sync_faucet().await; - clients.sync_recipient().await; - - tokio::time::sleep(std::time::Duration::from_millis(2000)).await; - let chain_height = dbg!(state_service_subscriber.chain_height().await.unwrap()); - if chain_height.0 >= 200 { - break; - } - - // create transactions - clients.shield_faucet().await; - clients.shield_recipient().await; - - clients - .send_from_faucet(recipient_taddr.as_str(), 250_000) - .await; - clients - .send_from_faucet(recipient_uaddr.as_str(), 250_000) - .await; - - clients - .send_from_recipient(faucet_taddr.as_str(), 200_000) - .await; - clients - .send_from_recipient(faucet_uaddr.as_str(), 200_000) - .await; - - // Generate block - test_manager - .generate_blocks_and_wait_for_tip(1, &state_service_subscriber) - .await; - - // sync wallets - clients.sync_faucet().await; - clients.sync_recipient().await; - - tokio::time::sleep(std::time::Duration::from_millis(2000)).await; - let chain_height = dbg!(state_service_subscriber.chain_height().await.unwrap()); - if chain_height.0 >= 200 { - break; - } - - // create transactions - clients.shield_faucet().await; - clients.shield_recipient().await; - - clients - .send_from_faucet(recipient_taddr.as_str(), 250_000) - .await; - clients - .send_from_faucet(recipient_saddr.as_str(), 250_000) - .await; - clients - .send_from_faucet(recipient_uaddr.as_str(), 250_000) - .await; - - clients - .send_from_recipient(faucet_taddr.as_str(), 250_000) - .await; - clients - .send_from_recipient(faucet_saddr.as_str(), 250_000) - .await; - - // Generate block - test_manager - .generate_blocks_and_wait_for_tip(1, &state_service_subscriber) - .await; - } - tokio::time::sleep(std::time::Duration::from_millis(10000)).await; - - // *** Fetch chain data *** - let chain_height = dbg!(state_service_subscriber.chain_height().await.unwrap()); - - //fetch and build block data - let block_data = { - let mut data = Vec::new(); - let mut parent_chain_work: Option = None; - let mut parent_block_sapling_tree_size: u32 = 0; - let mut parent_block_orchard_tree_size: u32 = 0; - let mut parent_block_ironwood_tree_size: u32 = 0; - - for height in 0..=chain_height.0 { - let (chain_block, zebra_block, block_roots, block_treestate) = { - // Fetch block data - let (_hash, tx, _trees) = state_service_subscriber - .z_get_block(height.to_string(), Some(1)) - .await - .and_then(|response| match response { - zebra_rpc::methods::GetBlock::Raw(_) => { - Err(zaino_state::NodeBackedIndexerServiceError::Custom( - "Found transaction of `Raw` type, expected only `Object` types." - .to_string(), - )) - } - zebra_rpc::methods::GetBlock::Object(block_obj) => Ok(( - block_obj.hash() , - block_obj.tx().iter() - .map(|item| { - match item { - GetBlockTransaction::Hash(h) => Ok(h.0.to_vec()), - GetBlockTransaction::Object(_) => Err( - zaino_state::NodeBackedIndexerServiceError::Custom( - "Found transaction of `Object` type, expected only `Hash` types." - .to_string(), - ), - ), - } - }) - .collect::, _>>() - .unwrap(), - (block_obj.trees().sapling(), block_obj.trees().orchard()), - )), - }) - .unwrap(); - - let block_data = state_service_subscriber - .z_get_block(height.to_string(), Some(0)) - .await - .and_then(|response| match response { - zebra_rpc::methods::GetBlock::Object { .. } => { - Err(zaino_state::NodeBackedIndexerServiceError::Custom( - "Found transaction of `Object` type, expected only `Raw` types." - .to_string(), - )) - } - zebra_rpc::methods::GetBlock::Raw(block_hex) => Ok(block_hex), - }) - .unwrap(); - - let mut state = state_service_subscriber.read_state_service(); - let (sapling_root, orchard_root) = { - let (sapling_tree_response, orchard_tree_response) = futures::future::join( - state.clone().call(zebra_state::ReadRequest::SaplingTree( - HashOrHeight::Height(zebra_chain::block::Height(height)), - )), - state.clone().call(zebra_state::ReadRequest::OrchardTree( - HashOrHeight::Height(zebra_chain::block::Height(height)), - )), - ) - .await; - let (sapling_tree, orchard_tree) = match ( - //TODO: Better readstateservice error handling - sapling_tree_response.unwrap(), - orchard_tree_response.unwrap(), - ) { - ( - zebra_state::ReadResponse::SaplingTree(saptree), - zebra_state::ReadResponse::OrchardTree(orctree), - ) => (saptree, orctree), - (_, _) => panic!("Bad response"), - }; - - ( - sapling_tree - .as_deref() - .map(|tree| (tree.root(), tree.count())) - .unwrap(), - orchard_tree - .as_deref() - .map(|tree| (tree.root(), tree.count())) - .unwrap(), - ) - }; - - let sapling_treestate = match zebra_chain::parameters::NetworkUpgrade::Sapling - .activation_height(&state_service_subscriber.network()) - { - Some(activation_height) if height >= activation_height.0 => Some( - state - .ready() - .and_then(|service| { - service.call(ReadRequest::SaplingTree(HashOrHeight::Height( - zebra_chain::block::Height(height), - ))) - }) - .await - .unwrap(), - ), - _ => Some(zebra_state::ReadResponse::SaplingTree(Some(Arc::new( - zebra_chain::sapling::tree::NoteCommitmentTree::default(), - )))), - } - .and_then(|sap_response| { - expected_read_response!(sap_response, SaplingTree) - .map(|tree| tree.to_rpc_bytes()) - }) - .unwrap(); - let orchard_treestate = match zebra_chain::parameters::NetworkUpgrade::Nu5 - .activation_height(&state_service_subscriber.network()) - { - Some(activation_height) if height >= activation_height.0 => Some( - state - .ready() - .and_then(|service| { - service.call(ReadRequest::OrchardTree(HashOrHeight::Height( - zebra_chain::block::Height(height), - ))) - }) - .await - .unwrap(), - ), - _ => Some(zebra_state::ReadResponse::OrchardTree(Some(Arc::new( - zebra_chain::orchard::tree::NoteCommitmentTree::default(), - )))), - } - .and_then(|orch_response| { - expected_read_response!(orch_response, OrchardTree) - .map(|tree| tree.to_rpc_bytes()) - }) - .unwrap(); - - // Build block data - let full_block = zaino_testutils::legacy_parser::block::FullBlock::parse_from_hex( - block_data.as_ref(), - Some(display_txids_to_server(tx.clone())), - ) - .unwrap(); - - let chain_block = zaino_testutils::legacy_parser::indexed_block_from_full_block( - full_block.clone(), - parent_chain_work, - sapling_root.0.into(), - orchard_root.0.into(), - None, - parent_block_sapling_tree_size, - parent_block_orchard_tree_size, - parent_block_ironwood_tree_size, - ) - .unwrap(); - - let zebra_block = - zebra_chain::block::Block::zcash_deserialize(block_data.as_ref()).unwrap(); - - let block_roots = ( - sapling_root.0, - chain_block.commitment_tree_data().sizes().sapling() as u64, - orchard_root.0, - chain_block.commitment_tree_data().sizes().orchard() as u64, - ); - - let block_treestate = (sapling_treestate, orchard_treestate); - - (chain_block, zebra_block, block_roots, block_treestate) - }; - - // Update parent block - parent_block_sapling_tree_size = chain_block.commitment_tree_data().sizes().sapling(); - parent_block_orchard_tree_size = chain_block.commitment_tree_data().sizes().orchard(); - parent_block_ironwood_tree_size = chain_block.commitment_tree_data().sizes().ironwood(); - parent_chain_work = Some(*chain_block.chainwork()); - - data.push((height, zebra_block, block_roots, block_treestate)); - } - data - }; - - // Fetch and build wallet addr transparent data - let faucet_data = { - let faucet_txids = state_service_subscriber - .get_address_tx_ids(GetAddressTxIdsRequest::new( - vec![faucet_taddr.clone()], - Some(0), - Some(chain_height.0), - )) - .await - .unwrap(); - - // The vectors are a JSON file format, so they hold the served wire - // shape rather than the serde-free domain type. - let faucet_utxos = - zaino_serve::rpc::jsonrpc::wire::address_queries::address_utxos_from_domain( - state_service_subscriber - .z_get_address_utxos(GetAddressBalanceRequest::new(vec![faucet_taddr.clone()])) - .await - .unwrap(), - ) - .unwrap(); - - let faucet_balance = u64::from( - state_service_subscriber - .z_get_address_balance(GetAddressBalanceRequest::new(vec![faucet_taddr.clone()])) - .await - .unwrap() - .balance, - ); - - (faucet_txids, faucet_utxos, faucet_balance) - }; - - // fetch recipient addr transparent data - let recipient_data = { - let recipient_txids = state_service_subscriber - .get_address_tx_ids(GetAddressTxIdsRequest::new( - vec![recipient_taddr.clone()], - Some(0), - Some(chain_height.0), - )) - .await - .unwrap(); - - // The vectors are a JSON file format, so they hold the served wire - // shape rather than the serde-free domain type. - let recipient_utxos = - zaino_serve::rpc::jsonrpc::wire::address_queries::address_utxos_from_domain( - state_service_subscriber - .z_get_address_utxos(GetAddressBalanceRequest::new(vec![ - recipient_taddr.clone() - ])) - .await - .unwrap(), - ) - .unwrap(); - - let recipient_balance = u64::from( - state_service_subscriber - .z_get_address_balance(GetAddressBalanceRequest::new(vec![recipient_taddr.clone()])) - .await - .unwrap() - .balance, - ); - - (recipient_txids, recipient_utxos, recipient_balance) - }; - - // *** Save chain vectors to disk *** - - let vec_dir = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("vectors_tmp"); - if vec_dir.exists() { - fs::remove_dir_all(&vec_dir).unwrap(); - } - - write_vectors_to_file(&vec_dir, &block_data, &faucet_data, &recipient_data).unwrap(); - - // *** Read data from files to validate write format. - - let (re_blocks, re_faucet, re_recipient) = read_vectors_from_file(&vec_dir).unwrap(); - - for ((h_orig, zebra_orig, roots_orig, trees_orig), (h_new, zebra_new, roots_new, trees_new)) in - block_data.iter().zip(re_blocks.iter()) - { - assert_eq!(h_orig, h_new, "height mismatch at block {h_orig}"); - assert_eq!( - zebra_orig, zebra_new, - "zebra_chain::block::Block serialisation mismatch at height {h_orig}" - ); - assert_eq!( - roots_orig, roots_new, - "block root serialisation mismatch at height {h_orig}" - ); - assert_eq!( - trees_orig, trees_new, - "block treestate serialisation mismatch at height {h_orig}" - ); - } - - assert_eq!(faucet_data, re_faucet, "faucet tuple mismatch"); - assert_eq!(recipient_data, re_recipient, "recipient tuple mismatch"); -} - -/// Test-only helper: takes big-endian hex‐encoded txids (`Vec>`) -/// and returns them as little-endian raw-byte vectors. -fn display_txids_to_server(txids: Vec>) -> Vec> { - txids - .into_iter() - .map(|mut t| { - t.reverse(); - t - }) - .collect() -} - -#[allow(clippy::type_complexity)] -pub fn write_vectors_to_file>( - base_dir: P, - block_data: &[( - u32, - zebra_chain::block::Block, - ( - zebra_chain::sapling::tree::Root, - u64, - zebra_chain::orchard::tree::Root, - u64, - ), - (Vec, Vec), - )], - faucet_data: &(Vec, Vec, u64), - recipient_data: &(Vec, Vec, u64), -) -> io::Result<()> { - let base = base_dir.as_ref(); - fs::create_dir_all(base)?; - - // zcash_blocks.dat - let mut zb_out = BufWriter::new(File::create(base.join("zcash_blocks.dat"))?); - for (h, zcash_block, _roots, _treestate) in block_data { - write_u32_le(&mut zb_out, *h)?; - let mut bytes = Vec::new(); - zcash_block.zcash_serialize(&mut bytes)?; - CompactSize::write(&mut zb_out, bytes.len())?; - zb_out.write_all(&bytes)?; - } - - // tree_roots.dat - let mut tr_out = BufWriter::new(File::create(base.join("tree_roots.dat"))?); - for (h, _blocks, (sapling_root, sapling_size, orchard_root, orchard_size), _treestate) in - block_data - { - write_u32_le(&mut tr_out, *h)?; - tr_out.write_all(&<[u8; 32]>::from(*sapling_root))?; - write_u64_le(&mut tr_out, *sapling_size)?; - tr_out.write_all(&<[u8; 32]>::from(*orchard_root))?; - write_u64_le(&mut tr_out, *orchard_size)?; - } - - // tree_states.dat - let mut ts_out = BufWriter::new(File::create(base.join("tree_states.dat"))?); - for (h, _blocks, _roots, (sapling_treestate, orchard_treestate)) in block_data { - write_u32_le(&mut ts_out, *h)?; - // Write length-prefixed treestate bytes (variable length) - CompactSize::write(&mut ts_out, sapling_treestate.len())?; - ts_out.write_all(sapling_treestate)?; - CompactSize::write(&mut ts_out, orchard_treestate.len())?; - ts_out.write_all(orchard_treestate)?; - } - - // faucet_data.json - serde_json::to_writer_pretty(File::create(base.join("faucet_data.json"))?, faucet_data)?; - - // recipient_data.json - serde_json::to_writer_pretty( - File::create(base.join("recipient_data.json"))?, - recipient_data, - )?; - - Ok(()) -} - -#[allow(clippy::type_complexity)] -pub fn read_vectors_from_file>( - base_dir: P, -) -> io::Result<( - Vec<( - u32, - zebra_chain::block::Block, - ( - zebra_chain::sapling::tree::Root, - u64, - zebra_chain::orchard::tree::Root, - u64, - ), - (Vec, Vec), - )>, - (Vec, Vec, u64), - (Vec, Vec, u64), -)> { - let base = base_dir.as_ref(); - - // zebra_blocks.dat - let mut zebra_blocks = Vec::<(u32, zebra_chain::block::Block)>::new(); - { - let mut r = BufReader::new(File::open(base.join("zcash_blocks.dat"))?); - loop { - let height = match read_u32_le(&mut r) { - Ok(h) => h, - Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break, - Err(e) => return Err(e), - }; - - let len: usize = CompactSize::read_t(&mut r)?; - let mut buf = vec![0u8; len]; - r.read_exact(&mut buf)?; - - let zcash_block = zebra_chain::block::Block::zcash_deserialize(&*buf) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - zebra_blocks.push((height, zcash_block)); - } - } - - // tree_roots.dat - let mut blocks_and_roots = Vec::with_capacity(zebra_blocks.len()); - { - let mut r = BufReader::new(File::open(base.join("tree_roots.dat"))?); - for (height, zebra_block) in zebra_blocks { - let h2 = read_u32_le(&mut r)?; - if height != h2 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "height mismatch in tree_roots.dat", - )); - } - let mut sapling_bytes = [0u8; 32]; - r.read_exact(&mut sapling_bytes)?; - let sapling_root = zebra_chain::sapling::tree::Root::try_from(sapling_bytes) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - let sapling_size = read_u64_le(&mut r)?; - - let mut orchard_bytes = [0u8; 32]; - r.read_exact(&mut orchard_bytes)?; - let orchard_root = zebra_chain::orchard::tree::Root::try_from(orchard_bytes) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - let orchard_size = read_u64_le(&mut r)?; - - blocks_and_roots.push(( - height, - zebra_block, - (sapling_root, sapling_size, orchard_root, orchard_size), - )); - } - } - - // tree_states.dat - let mut full_data = Vec::with_capacity(blocks_and_roots.len()); - { - let mut r = BufReader::new(File::open(base.join("tree_states.dat"))?); - for (height, zebra_block, roots) in blocks_and_roots { - let h2 = read_u32_le(&mut r)?; - if height != h2 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "height mismatch in tree_states.dat", - )); - } - - let sapling_len: usize = CompactSize::read_t(&mut r)?; - let mut sapling_state = vec![0u8; sapling_len]; - r.read_exact(&mut sapling_state)?; - - let orchard_len: usize = CompactSize::read_t(&mut r)?; - let mut orchard_state = vec![0u8; orchard_len]; - r.read_exact(&mut orchard_state)?; - - full_data.push((height, zebra_block, roots, (sapling_state, orchard_state))); - } - } - - // faucet_data.json - let faucet = serde_json::from_reader(File::open(base.join("faucet_data.json"))?) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - // recipient_data.json - let recipient = serde_json::from_reader(File::open(base.join("recipient_data.json"))?) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - Ok((full_data, faucet, recipient)) -} diff --git a/live-tests/e2e/tests/wallet_to_validator.rs b/live-tests/e2e/tests/wallet_to_validator.rs new file mode 100644 index 00000000..e71b7f14 --- /dev/null +++ b/live-tests/e2e/tests/wallet_to_validator.rs @@ -0,0 +1,3787 @@ +//! Wallet-to-validator end-to-end tests: an in-process librustzcash wallet +//! (faucet + recipient) drives a Zaino-in-a-pod over its gRPC / JSON-RPC +//! surface. +//! +//! Covered (the full query/send surface): +//! - sends to each pool, `send_to_all`, shielding, mining-reward receipt; +//! - `get_transaction` (mined / mempool), `get_raw_transaction`; +//! - mempool: `get_raw_mempool`, `get_mempool_tx`, `get_mempool_stream`, +//! `get_mempool_info`; +//! - address queries: `get_address_tx_ids`, `get_address_utxos`, +//! `get_address_balance`, the `get_taddress_*` family (recipient and +//! faucet-coinbase variants), `get_address_transactions_regtest`; +//! - tree state: `z_get_treestate`, `z_get_subtrees_by_index`; +//! - block range: default/all pools and the out-of-range edge cases; +//! - compact-block transparent data; +//! - `getblockdeltas` spend-resolution (AP-03 / Zellic #48500) and the +//! coinbase-only edge case; +//! - `getaddressdeltas` across its five request shapes; +//! - `connect_to_node_get_info` (wallet `get_info` smoke). +//! +//! Dual `*_fetch_vs_state` tests compare zaino's two ingest paths. `backend = 'fetch'` +//! (`Rpc`) reaches the validator over JSON-RPC alone; `backend = 'state'` (`Direct`) +//! additionally runs a zebra `ReadStateService` it syncs itself, and the routing table +//! prefers it for every query it can answer. Both arms build the same chain index through +//! one `NodeBackedIndexerService`, so the axis under test is the transport, not the index. +//! +//! The ingest path and the validator backend are one flat case list, not a matrix: +//! `State` reads the validator's own on-disk zebra DB over a shared volume, which only +//! the zebra backend writes. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use rstest::rstest; +use serde_json::{json, Value}; +use zaino_testutils::{assert_rpc_parity, wait_for_finalised}; +use ztest::prelude::*; + +use e2e::{assert_pool_absent, assert_pool_present, Pool}; + +/// Indexer sync / pod-ready timeout. +const READY: Duration = Duration::from_secs(120); +/// Standard transfer amount (zatoshis). +const SEND_AMOUNT: u64 = 250_000; +/// zingolib's ZIP-317 fee for a single-note shield round under regtest. +const SHIELD_FEE: u64 = 15_000; +/// Shielded funding pool for the faucet coinbase: the miner coinbase pays the +/// Orchard receiver of a unified address. Under this file's NU6.3-active +/// regtest schedule (Ironwood live from height 2), that note is credited to the +/// Ironwood pool — hence `receives_mining_reward` asserts an Ironwood balance. +const FUND: Pool = Pool::Orchard; +/// Blocks to mine past a transaction's block to bury it below the finalisation +/// seam (so it crosses `tip - seam` into the finalized DB): +/// `FAST_TEST_MAX_NONFINALISED_DEPTH` (100, under `fast-test-seam`) plus margin. +/// Inlined because this crate links no production code. +const SEAM_ADVANCE: u32 = 105; +/// Longest the finalised writer should need to commit up to a height the +/// validator already serves. It commits per batch, so the frontier moves in +/// steps rather than per block. +const SEAM_TIMEOUT: Duration = Duration::from_secs(300); + +/// Wallet-driven flows. +mod wallet { + use super::*; + + /// The indexer's ingest path. `Fetch` reaches the validator over JSON-RPC alone; + /// `State` additionally runs a zebra `ReadStateService`, synced over the validator's + /// indexer gRPC into the zebra db on the shared volume, and prefers it for every + /// query it can answer. Only zebrad writes that db, so `State` pairs with zebrad only. + #[derive(Copy, Clone, Debug)] + enum Backend { + Fetch, + State, + } + + /// The faucet's synced wallet holds a spendable shielded coinbase note. + #[rstest] + #[case::fetch(Validator::zebrad("6.2.3"), Backend::Fetch)] + #[case::state(Validator::zebrad("6.2.3"), Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn receives_mining_reward( + #[case] validator: Validator, + #[case] backend: Backend, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + // NU6.3 regtest: orchard coinbase -> Ironwood + let credited = Pool::Ironwood; + let base = validator.regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let balances = faucet.balances().await?; + assert!( + balances.get(credited.ztest()) > 0, + "faucet must hold a spendable {credited:?} coinbase note, got {balances:?}" + ); + Ok(()) + } + + /// Smoke: faucet and recipient wallets connect and sync without error, + /// and the indexer reports node info. + #[rstest] + #[case::fetch(Validator::zebrad("6.2.3"), Backend::Fetch)] + #[case::state(Validator::zebrad("6.2.3"), Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn connect_to_node_get_info( + #[case] validator: Validator, + #[case] backend: Backend, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let base = validator.regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let _faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + recipient.sync().await?; + assert!(!indexer.indexer_info().await?.chain_name.is_empty()); + Ok(()) + } + + /// The faucet sends 250_000 to the recipient's unified address, whose Orchard + /// receiver credits Ironwood from NU6.3 (Orchard is spend-locked) and Orchard + /// before it. + #[rstest] + #[case::fetch(Validator::zebrad("6.2.3"), Backend::Fetch)] + #[case::state(Validator::zebrad("6.2.3"), Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn send_to_unified( + #[case] validator: Validator, + #[case] backend: Backend, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let credited = Pool::Ironwood; + let base = validator.regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let addr = recipient.address(credited.ztest()).await?; + faucet.send(&addr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + assert_eq!( + recipient.balances().await?.get(credited.ztest()), + SEND_AMOUNT, + "recipient {credited:?} balance must equal the send" + ); + Ok(()) + } + + /// The faucet sends 250_000 to the recipient's sapling address; the + /// recipient's synced wallet shows it. + #[rstest] + #[case::fetch(Backend::Fetch)] + #[case::state(Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn send_to_sapling(#[case] backend: Backend) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let base = Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let addr = recipient.address(Pool::Sapling.ztest()).await?; + faucet.send(&addr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + assert_eq!( + recipient.balances().await?.get(Pool::Sapling.ztest()), + SEND_AMOUNT, + "recipient Sapling balance must equal the send" + ); + Ok(()) + } + + /// The faucet sends 250_000 to the recipient's transparent address; the + /// recipient's synced wallet shows it. + #[rstest] + #[case::fetch(Validator::zebrad("6.2.3"), Backend::Fetch)] + #[case::state(Validator::zebrad("6.2.3"), Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn send_to_transparent( + #[case] validator: Validator, + #[case] backend: Backend, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let base = validator.regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let addr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&addr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + assert_eq!( + recipient.balances().await?.get(Pool::Transparent.ztest()), + SEND_AMOUNT, + "recipient Transparent balance must equal the send" + ); + Ok(()) + } + + /// One faucet funds a send to all three pools; each recipient pool + /// reports 250_000. + #[rstest] + #[case::fetch(Backend::Fetch)] + #[case::state(Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn send_to_all(#[case] backend: Backend) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let base = Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + // Three notes — one per send (no chaining of unconfirmed change). + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 3) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + // NU6.3: the unified-address (Orchard-receiver) output routes to Ironwood. + for pool in [Pool::Ironwood, Pool::Sapling, Pool::Transparent] { + let addr = recipient.address(pool.ztest()).await?; + faucet.send(&addr, SEND_AMOUNT).await?; + } + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + + let balances = recipient.balances().await?; + assert_eq!(balances.get(Pool::Ironwood.ztest()), SEND_AMOUNT); + // From NU6.3 the unified-address output routes to Ironwood; the + // orchard pool must stay empty (a nonzero orchard here means the + // receipt was mislabelled, not merely misrouted). + assert_eq!(balances.get(Pool::Orchard.ztest()), 0); + assert_eq!(balances.get(Pool::Sapling.ztest()), SEND_AMOUNT); + assert_eq!(balances.get(Pool::Transparent.ztest()), SEND_AMOUNT); + Ok(()) + } + + /// The recipient receives a transparent 250_000, shields it, and reports + /// 250_000 − 15_000 fee in the pool the chain's latest activation shields into. + #[rstest] + #[case::fetch(Validator::zebrad("6.2.3"), Backend::Fetch)] + #[case::state(Validator::zebrad("6.2.3"), Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn shield_for_validator( + #[case] validator: Validator, + #[case] backend: Backend, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let credited = Pool::Ironwood; + let base = validator.regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&taddr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + assert_eq!( + recipient.balances().await?.get(Pool::Transparent.ztest()), + SEND_AMOUNT + ); + + recipient.shield().await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + assert_eq!( + recipient.balances().await?.get(credited.ztest()), + SEND_AMOUNT - SHIELD_FEE, + "shielded balance must be the send net of the ZIP-317 fee, in {credited:?}" + ); + Ok(()) + } + + /// A transparent send returns the same address txids from the + /// non-finalised chain and again after a seam-deep advance lands it in + /// the finalised DB. + /// + /// - `fast-test-seam` image: at the shipped depth (1001) `SEAM_ADVANCE` buries nothing + /// → both reads come from the non-finalised cache, compare passes vacuously + /// - `wait_for_finalised` = the loud check against exactly that + /// - Advance mined to `FILLER_ADDRESS`: on this file's `FUND` coinbase it is + /// `SEAM_ADVANCE` halo2 proofs, past the tier's cap on the two cores it reserves + #[rstest] + #[case::fetch(Validator::zebrad("6.2.3"), Backend::Fetch)] + #[case::state(Validator::zebrad("6.2.3"), Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn send_to_transparent_finalization( + #[case] validator: Validator, + #[case] backend: Backend, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let base = validator.regtest().mine_to(FUND.ztest()); + // `fast-test-seam` selects the 100-block seam over the shipped 1001, which no + // regtest fixture can bury a transaction below; `prometheus` publishes the + // gauge `wait_for_finalised` polls. A `features` list replaces the zainod + // defaults, so the two `no_tls` ones are repeated here. + let image = dev!( + Indexer::Zainod, + "../../Dockerfile", + features = [ + "no_tls_use_unencrypted_traffic", + "allow_unencrypted_public_json_rpc_bind", + "prometheus", + "fast-test-seam", + ] + ); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&taddr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + // The send's block, queried while it is still in the non-finalised window. + let irpc = indexer.json_rpc().await?; + let height = u32::from(indexer.latest_block_height().await?); + let unfinalised_txids = irpc + .call_value( + "getaddresstxids", + serde_json::json!([{ "addresses": [&taddr], "start": height, "end": height }]), + ) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + + // The load-bearing advance: push the send below the seam so it crosses + // the finalised floor (`tip - seam`) into the finalized DB. + let tip = validator.generate_blocks(SEAM_ADVANCE).to(FILLER_ADDRESS).await?; + indexer.wait_for_block_num(tip, READY).await?; + + // Without this the test is vacuous: it would compare two reads that + // both came from the non-finalised cache. `index_frontier` is the + // only observable that says the finalised writer committed the + // send's block — a served height proves nothing, because below the + // seam zaino can answer straight from the validator it proxies. + wait_for_finalised(&indexer, height, SEAM_TIMEOUT).await?; + + let finalised_txids = irpc + .call_value( + "getaddresstxids", + serde_json::json!([{ "addresses": [&taddr], "start": height, "end": height }]), + ) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + + recipient.sync().await?; + assert_eq!( + recipient.balances().await?.get(Pool::Transparent.ztest()), + SEND_AMOUNT, + "the transparent send must still be served after it finalizes" + ); + assert_eq!( + unfinalised_txids, finalised_txids, + "the address txids must be identical across the finalisation seam" + ); + Ok(()) + } + + /// Smoke: the indexer serves `get_transaction` for the mined orchard + /// send by txid. + #[rstest] + #[case::fetch(Backend::Fetch)] + #[case::state(Backend::State)] + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + async fn get_transaction_mined(#[case] backend: Backend) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let base = Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let addr = recipient.address(Pool::Orchard.ztest()).await?; + let txid = faucet + .send(&addr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let served = indexer.get_transaction(txid).await?; + // A mined transaction reports its block height, not the mempool sentinel 0. + assert_eq!(served.height, u64::from(u32::from(tip))); + let raw = validator + .json_rpc() + .await? + .call_value( + "getrawtransaction", + serde_json::json!([txid.to_string(), 0]), + ) + .await?; + assert_eq!( + zaino_testutils::hex::encode(&served.data), + raw.as_str() + .context("getrawtransaction returns a hex string")?, + "zaino must serve the bytes the validator holds" + ); + Ok(()) + } + + /// The indexer's `getrawmempool` holds exactly the transactions the + /// validator's does. The txid set order is not contractual, so both + /// sides are sorted; the two broadcast txids keep `[] == []` from + /// passing. + #[rstest] + #[case::fetch(Validator::zebrad("6.2.3"), Backend::Fetch)] + #[case::state(Validator::zebrad("6.2.3"), Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn get_raw_mempool( + #[case] validator: Validator, + #[case] backend: Backend, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let base = validator.regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 2) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + let t_txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let u_txid = faucet + .send(&ua, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + let want = [t_txid.to_string(), u_txid.to_string()]; + // Zaino's mempool is a polled mirror (500 ms cadence), so the two agree only + // eventually; `want` is the non-vacuity probe — a send that was built but never + // relayed leaves both sides empty and equal. + let mempool = { + let deadline = tokio::time::Instant::now() + READY; + loop { + let mut validator_txids = vrpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + validator_txids.sort(); + let mut indexer_txids = irpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + indexer_txids.sort(); + if validator_txids == indexer_txids + && want.iter().all(|txid| validator_txids.contains(txid)) + { + break validator_txids; + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "indexer mempool {indexer_txids:?} never converged on validator \ + mempool {validator_txids:?} holding {want:?}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + }; + + assert_eq!( + mempool.len(), + want.len(), + "the mirror must hold the two broadcast txs and nothing else: {mempool:?}" + ); + Ok(()) + } + + /// `get_mempool_tx` returns the two unmined transactions, and the + /// exclude-by-txid-suffix filter drops one. + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn get_mempool_tx() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = + env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 2) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + let t_txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("txid"); + let u_txid = faucet + .send(&ua, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("txid"); + + // The validator fixes what the mempool holds before GetMempoolTx is + // asked; without it a count assertion only says zaino agrees with itself. + let want = [t_txid.to_string(), u_txid.to_string()]; + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + // Zaino's mempool is a polled mirror (500 ms cadence), so the two agree only + // eventually; `want` is the non-vacuity probe — a send that was built but never + // relayed leaves both sides empty and equal. + let mempool = { + let deadline = tokio::time::Instant::now() + READY; + loop { + let mut validator_txids = vrpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + validator_txids.sort(); + let mut indexer_txids = irpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + indexer_txids.sort(); + if validator_txids == indexer_txids + && want.iter().all(|txid| validator_txids.contains(txid)) + { + break validator_txids; + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "indexer mempool {indexer_txids:?} never converged on validator \ + mempool {validator_txids:?} holding {want:?}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + }; + assert_eq!( + mempool.len(), + 2, + "the validator must hold exactly the two broadcast txs: {mempool:?}" + ); + + let mut want = [t_txid.as_ref().to_vec(), u_txid.as_ref().to_vec()]; + want.sort(); + + let mut all = indexer.get_mempool_tx(Vec::new()).await?; + all.sort_by_key(|tx| tx.txid.clone()); + assert_eq!(all.len(), 2, "both unmined txs must be present"); + assert_eq!(all[0].txid, want[0]); + assert_eq!(all[1].txid, want[1]); + + // Excluding the first by its txid suffix leaves only the second. + let remaining = indexer.get_mempool_tx(vec![want[0][8..].to_vec()]).await?; + assert_eq!(remaining.len(), 1, "excluding one leaves the other"); + assert_eq!(remaining[0].txid, want[1]); + Ok(()) + } + + /// The stream serves exactly the transaction bytes the validator holds + /// unmined. + /// + /// GetMempoolStream is bound to the chain tip the request was admitted + /// against and ends when that tip moves, so the drain can only complete + /// once a block is mined — hence the spawn-then-mine shape. The + /// per-transaction bytes come from the validator's `getrawtransaction`, + /// so this catches a mirror that is missing, over-full, or serving the + /// wrong bytes; `!is_empty()` alone caught none of those. + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn get_mempool_stream() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = + env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 2) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + let t_txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let u_txid = faucet + .send(&ua, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + let want = [t_txid.to_string(), u_txid.to_string()]; + // Zaino's mempool is a polled mirror (500 ms cadence), so the two agree only + // eventually; `want` is the non-vacuity probe — a send that was built but never + // relayed leaves both sides empty and equal. + let mempool = { + let deadline = tokio::time::Instant::now() + READY; + loop { + let mut validator_txids = vrpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + validator_txids.sort(); + let mut indexer_txids = irpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + indexer_txids.sort(); + if validator_txids == indexer_txids + && want.iter().all(|txid| validator_txids.contains(txid)) + { + break validator_txids; + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "indexer mempool {indexer_txids:?} never converged on validator \ + mempool {validator_txids:?} holding {want:?}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + }; + // The validator's own bytes for each mirrored txid: the independent oracle for + // what zaino streams. + let mut expected = Vec::with_capacity(mempool.len()); + for txid in &mempool { + expected.push( + vrpc.call_value("getrawtransaction", json!([txid])) + .await? + .as_str() + .with_context(|| format!("getrawtransaction {txid} returns hex"))? + .to_string(), + ); + } + expected.sort(); + + let drain = tokio::spawn({ + let indexer = indexer.clone(); + async move { indexer.get_mempool_stream().await } + }); + // Mining before the subscription is admitted leaves the drain waiting + // on the *next* tip change, by which point the mempool is empty. + // Nothing observable reports that the stream is open, so this is a + // grace period rather than a handshake. The missing seam is a + // stream-established signal, e.g. + // let (ready, drain) = indexer.get_mempool_stream_handle().await?; + // ready.await?; // resolves once zaino has admitted the subscription + tokio::time::sleep(Duration::from_secs(5)).await; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let txs = drain.await.expect("mempool-stream drain task joins")?; + let mut streamed: Vec = txs + .iter() + .map(|tx| zaino_testutils::hex::encode(&tx.data)) + .collect(); + streamed.sort(); + assert_eq!( + streamed, expected, + "the mempool stream must serve the validator's unmined transactions verbatim" + ); + assert!( + txs.iter().all(|tx| tx.height == 0), + "unmined transactions carry the height-0 mempool sentinel" + ); + Ok(()) + } + + /// `getmempoolinfo` agrees with the **validator's own** `getmempoolinfo`. + /// + /// `size` and `bytes` are the same quantities on both sides (tx count, + /// Σ serialized sizes), so they are compared directly — that is the + /// cross-check that zaino's mirror holds the transactions the validator + /// does, rather than that zaino agrees with itself. `usage` is not: + /// zaino reports the ZIP-401 cost total, each transaction floored at + /// `max(size, 10_000)` because that is the figure its own memory bound + /// is enforced against, while zebra reports its own memory estimate. + /// The floor makes `usage >= bytes` the only contractual relation. + #[rstest] + #[case::fetch(Validator::zebrad("6.2.3"), Backend::Fetch)] + #[case::state(Validator::zebrad("6.2.3"), Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn get_mempool_info( + #[case] validator: Validator, + #[case] backend: Backend, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let base = validator.regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 2) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + let t_txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let u_txid = faucet + .send(&ua, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + let want = [t_txid.to_string(), u_txid.to_string()]; + // Zaino's mempool is a polled mirror (500 ms cadence), so the two agree only + // eventually; `want` is the non-vacuity probe — a send that was built but never + // relayed leaves both sides empty and equal. + let mempool = { + let deadline = tokio::time::Instant::now() + READY; + loop { + let mut validator_txids = vrpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + validator_txids.sort(); + let mut indexer_txids = irpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + indexer_txids.sort(); + if validator_txids == indexer_txids + && want.iter().all(|txid| validator_txids.contains(txid)) + { + break validator_txids; + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "indexer mempool {indexer_txids:?} never converged on validator \ + mempool {validator_txids:?} holding {want:?}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + }; + + let validator_info = vrpc.call_value("getmempoolinfo", json!([])).await?; + let indexer_info = irpc.call_value("getmempoolinfo", json!([])).await?; + // No block is mined and no send is in flight here, but re-reading the + // set proves the totals were taken over one quiescent mempool. + anyhow::ensure!( + { + let mut txids = vrpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + txids.sort(); + txids + } == mempool, + "the mempool moved across the two getmempoolinfo reads; totals are \ + not comparable" + ); + + let size = indexer_info + .get("size") + .and_then(serde_json::Value::as_u64) + .with_context(|| { + format!("getmempoolinfo must report a numeric `size`: {indexer_info}") + })?; + let bytes = indexer_info + .get("bytes") + .and_then(serde_json::Value::as_u64) + .with_context(|| { + format!("getmempoolinfo must report a numeric `bytes`: {indexer_info}") + })?; + let usage = indexer_info + .get("usage") + .and_then(serde_json::Value::as_u64) + .with_context(|| { + format!("getmempoolinfo must report a numeric `usage`: {indexer_info}") + })?; + + assert_eq!( + size, + mempool.len() as u64, + "size must equal the mirrored txid count" + ); + assert_eq!( + size, + validator_info + .get("size") + .and_then(serde_json::Value::as_u64) + .with_context(|| format!( + "getmempoolinfo must report a numeric `size`: {validator_info}" + ))?, + "zaino's mempool holds a different number of transactions than the \ + validator's" + ); + assert_eq!( + bytes, + validator_info + .get("bytes") + .and_then(serde_json::Value::as_u64) + .with_context(|| format!( + "getmempoolinfo must report a numeric `bytes`: {validator_info}" + ))?, + "zaino's serialized-byte total disagrees with the validator's" + ); + assert!(bytes > 0, "two unmined transactions cannot weigh nothing"); + assert!( + usage >= bytes, + "usage must be at least bytes: {indexer_info}" + ); + Ok(()) + } + + /// Broadcast two unmined sends, observe them in zaino's mirror of the + /// validator's mempool, then mine them in and confirm the balances. The + /// *unconfirmed* (mempool) pool-balance split under test cannot be + /// asserted — ztest's librustzcash wallet exposes no pending/unconfirmed + /// pool-balance accessor — so the confirmed balances stand in. + #[rstest] + #[case::fetch(Backend::Fetch)] + #[case::state(Backend::State)] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn monitor_unverified_mempool(#[case] backend: Backend) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let base = Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest()); + let image = dev!(Indexer::Zainod, "../../Dockerfile"); + let (validator, indexer) = match backend { + Backend::Fetch => (env.add_validator(base), env.add_indexer(image.regtest())), + Backend::State => { + let vol = env.shared_volume("zebra-db"); + ( + env.add_validator(base.mount(&vol)), + env.add_indexer( + image + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ), + ) + } + }; + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + // Two shielded notes — one per unmined send. + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 2) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let ua = recipient.address(Pool::Ironwood.ztest()).await?; + let zaddr = recipient.address(Pool::Sapling.ztest()).await?; + let ua_txid = faucet + .send(&ua, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let sapling_txid = faucet + .send(&zaddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + + let want = [ua_txid.to_string(), sapling_txid.to_string()]; + let vrpc = validator.json_rpc().await?; + let irpc = indexer.json_rpc().await?; + // Zaino's mempool is a polled mirror (500 ms cadence), so the two agree only + // eventually; `want` is the non-vacuity probe — a send that was built but never + // relayed leaves both sides empty and equal. + let mempool = { + let deadline = tokio::time::Instant::now() + READY; + loop { + let mut validator_txids = vrpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + validator_txids.sort(); + let mut indexer_txids = irpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + indexer_txids.sort(); + if validator_txids == indexer_txids + && want.iter().all(|txid| validator_txids.contains(txid)) + { + break validator_txids; + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "indexer mempool {indexer_txids:?} never converged on validator \ + mempool {validator_txids:?} holding {want:?}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + }; + assert_eq!( + mempool.len(), + 2, + "the mirror must hold the two broadcast txs and nothing else: {mempool:?}" + ); + + // `PoolBalances` is confirmed-only, so an unmined send must credit + // nothing however visible it is in the mempool mirror above. + recipient.sync().await?; + let unconfirmed = recipient.balances().await?; + assert_eq!( + unconfirmed.get(Pool::Ironwood.ztest()), + 0, + "an unmined send must not count toward a confirmed balance" + ); + assert_eq!(unconfirmed.get(Pool::Sapling.ztest()), 0); + + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + recipient.sync().await?; + let balances = recipient.balances().await?; + assert_eq!(balances.get(Pool::Ironwood.ztest()), SEND_AMOUNT); + assert_eq!(balances.get(Pool::Sapling.ztest()), SEND_AMOUNT); + Ok(()) + } + + /// `getaddresstxids` over the recipient's taddr returns the send's txid + /// (asserted as the first txid returned), identically to the validator's own. + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn get_address_tx_ids(#[case] validator: Validator) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let height = u32::from(indexer.latest_block_height().await?); + let params = json!([{ + "addresses": [taddr], + "start": height.saturating_sub(2), + "end": height, + }]); + let validator_txids = validator + .json_rpc() + .await? + .call_value("getaddresstxids", params.clone()) + .await?; + let indexer_txids = indexer + .json_rpc() + .await? + .call_value("getaddresstxids", params) + .await?; + assert_eq!( + validator_txids + .as_array() + .and_then(|a| a.first()) + .and_then(Value::as_str), + Some(txid.to_string().as_str()), + "getaddresstxids first txid must be the send {txid}, got {validator_txids}" + ); + assert_eq!(validator_txids, indexer_txids); + Ok(()) + } + + /// `getaddressutxos` over the recipient's taddr returns a utxo whose txid is + /// the send's, over both the gRPC and the JSON-RPC surface, and the JSON-RPC + /// one matches the validator's own. + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn get_address_utxos(#[case] validator: Validator) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let utxos = indexer + .get_address_utxos(vec![taddr.clone()], BlockHeight::from(0u32), 0) + .await?; + assert_eq!( + utxos[0].txid, + txid.as_ref().to_vec(), + "utxo[0] txid must be the send" + ); + + let params = json!([{"addresses": [taddr]}]); + let validator_utxos = validator + .json_rpc() + .await? + .call_value("getaddressutxos", params.clone()) + .await?; + let indexer_utxos = indexer + .json_rpc() + .await? + .call_value("getaddressutxos", params) + .await?; + let validator_txid = validator_utxos + .as_array() + .and_then(|a| a.first()) + .and_then(|u| u.get("txid")) + .and_then(Value::as_str) + .with_context(|| format!("validator utxo[0].txid: {validator_utxos}"))?; + let indexer_txid = indexer_utxos + .as_array() + .and_then(|a| a.first()) + .and_then(|u| u.get("txid")) + .and_then(Value::as_str) + .with_context(|| format!("indexer utxo[0].txid: {indexer_utxos}"))?; + assert_eq!(validator_txid, txid.to_string()); + assert_eq!(validator_txid, indexer_txid); + Ok(()) + } + + /// The tree state at the tip carries the validator's best block hash and a + /// non-empty tree per shielded protocol, and matches the validator's + /// `z_gettreestate` field for field. + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn z_get_treestate(#[case] validator: Validator) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let addr = recipient.address(Pool::Orchard.ztest()).await?; + faucet.send(&addr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let tip = indexer.latest_block_height().await?; + let tree = indexer.get_tree_state(tip).await?; + assert_eq!(tree.height, u64::from(u32::from(tip))); + let best = validator + .json_rpc() + .await? + .call_value("getbestblockhash", json!([])) + .await?; + assert_eq!( + tree.hash.as_str(), + best.as_str() + .context("getbestblockhash returns a hex string")? + ); + assert!(!tree.sapling_tree.is_empty(), "sapling tree must be served"); + assert!(!tree.orchard_tree.is_empty(), "orchard tree must be served"); + + assert_rpc_parity( + "z_gettreestate", + &format!(r#"["{}"]"#, u32::from(tip)), + &validator.json_rpc().await?, + &indexer.json_rpc().await?, + &[], + ) + .await?; + Ok(()) + } + + /// No regtest chain completes a subtree, so the roots are empty — and empty + /// identically to the validator's own `z_getsubtreesbyindex`. + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn z_get_subtrees_by_index( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let addr = recipient.address(Pool::Orchard.ztest()).await?; + faucet.send(&addr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + // A subtree completes every 2^16 notes, which no regtest chain reaches; + // a synthesised root would fail this. + let roots = indexer + .get_subtree_roots(0, ShieldedProtocol::Orchard, 0) + .await?; + assert!(roots.is_empty(), "regtest completes no subtree: {roots:?}"); + + assert_rpc_parity( + "z_getsubtreesbyindex", + r#"["orchard", 0]"#, + &validator.json_rpc().await?, + &indexer.json_rpc().await?, + &[], + ) + .await?; + Ok(()) + } + + /// `getrawtransaction` for the shielded send's txid reports the confirming + /// height, identically to the validator's own answer. + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn get_raw_transaction( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let addr = recipient.address(Pool::Orchard.ztest()).await?; + let txid = faucet + .send(&addr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let served = assert_rpc_parity( + "getrawtransaction", + &format!(r#"["{txid}", 1]"#), + &validator.json_rpc().await?, + &indexer.json_rpc().await?, + &[], + ) + .await?; + assert_eq!( + served.get("txid").and_then(Value::as_str), + Some(txid.to_string().as_str()) + ); + assert_eq!( + served.get("height").and_then(Value::as_u64), + Some(u64::from(u32::from(tip))), + "a mined transaction reports its block height" + ); + Ok(()) + } + + /// Smoke: `get_taddress_txids` over the recipient's taddr and a range + /// around the send succeeds. + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + async fn get_taddress_txids() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = + env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let tip = indexer.latest_block_height().await?; + let start = BlockHeight::from(u32::from(tip).saturating_sub(2)); + let txs = indexer.get_taddress_txids(taddr, start, tip).await?; + assert_eq!(txs.len(), 1, "the span holds exactly the one send"); + assert_eq!( + zaino_testutils::hex::encode(&txs[0].data), + validator + .json_rpc() + .await? + .call_value( + "getrawtransaction", + serde_json::json!([txid.to_string(), 0]) + ) + .await? + .as_str() + .context("getrawtransaction returns a hex string")? + ); + Ok(()) + } + + /// Smoke: `get_address_utxos` over the recipient's taddr succeeds. + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + async fn get_taddress_utxos() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = + env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&taddr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let utxos = indexer + .get_address_utxos(vec![taddr.clone()], BlockHeight::from(0u32), 0) + .await?; + assert_eq!(utxos.len(), 1, "the send leaves exactly one utxo"); + assert_eq!(utxos[0].address, taddr); + assert_eq!(utxos[0].value_zat, SEND_AMOUNT as i64); + assert_eq!(utxos[0].height, u64::from(u32::from(tip))); + Ok(()) + } + + /// Smoke: `get_address_utxos_stream` over the recipient's taddr + /// succeeds. + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + async fn get_taddress_utxos_stream() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = + env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&taddr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let streamed = indexer + .get_address_utxos_stream(vec![taddr.clone()], BlockHeight::from(0u32), 0) + .await?; + assert_eq!(streamed.len(), 1, "the send leaves exactly one utxo"); + assert_eq!(streamed[0].address, taddr); + assert_eq!(streamed[0].value_zat, SEND_AMOUNT as i64); + // The stream and unary forms serve the same set. + assert_eq!( + streamed, + indexer + .get_address_utxos(vec![taddr], BlockHeight::from(0u32), 0) + .await? + ); + Ok(()) + } + + /// `get_transaction` over an unmined orchard send, then over the same + /// transaction once mined — the mempool-to-mined transition, end to end. + /// + /// The invariants, in order: + /// - an unmined transaction carries the mempool height sentinel (`0`), + /// not the current tip height. This is the whole point of the test: + /// returning the tip would make an unconfirmed transaction look + /// confirmed to every wallet on the other end of the wire. + /// - the bytes zaino serves are the transaction that was actually + /// broadcast. The validator's `getrawtransaction` is the oracle — + /// comparing against a hash zaino also computed would only prove + /// zaino agrees with itself. + /// - once mined, the same query reports the real confirmation height, + /// not the sentinel. + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn get_transaction_mempool() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = + env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let addr = recipient.address(Pool::Orchard.ztest()).await?; + let txid = faucet + .send(&addr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("txid"); + tokio::time::sleep(Duration::from_secs(1)).await; + + let unmined = indexer.get_transaction(txid).await?; + assert_eq!( + unmined.height, 0, + "an unmined transaction must carry the mempool height sentinel, \ + not the tip height" + ); + + // The validator is the independent oracle for what was broadcast. + let vrpc = validator.json_rpc().await?; + let raw_hex = vrpc + .call_value("getrawtransaction", json!([txid.to_string()])) + .await?; + let raw_hex = raw_hex + .as_str() + .context("getrawtransaction must return a hex string at verbosity 0")?; + assert_eq!( + zaino_testutils::hex::encode(&unmined.data), + raw_hex, + "get_transaction served different bytes than the validator holds \ + for {txid}" + ); + + // Mine it: the same query must flip off the sentinel onto the real + // confirmation height. + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let mined = indexer.get_transaction(txid).await?; + assert_eq!( + mined.height, + u64::from(u32::from(tip)), + "a mined transaction must report the height of the block that \ + confirmed it, not the mempool sentinel" + ); + assert_eq!( + mined.data, unmined.data, + "mining must not change the transaction bytes zaino serves" + ); + Ok(()) + } + + /// `getaddressbalance` over the recipient's taddr reports exactly 250_000, + /// identically to the validator's own answer. + #[rstest] + #[case::zebra(Validator::zebrad("6.2.3"))] + #[ztest::qos::wallet] + #[tokio::test(flavor = "multi_thread")] + async fn get_address_balance( + #[case] validator: Validator, + ) -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = env.add_validator(validator.regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&taddr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let balance = assert_rpc_parity( + "getaddressbalance", + &format!(r#"[{{"addresses": ["{taddr}"]}}]"#), + &validator.json_rpc().await?, + &indexer.json_rpc().await?, + &[], + ) + .await?; + assert_eq!( + balance.get("balance").and_then(Value::as_u64), + Some(SEND_AMOUNT), + "getaddressbalance must report the send amount: {balance}" + ); + Ok(()) + } + + /// `GetTaddressBalance` over the recipient's taddr reports 250_000. + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + async fn get_taddress_balance() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let validator = + env.add_validator(Validator::zebrad("6.2.3").regtest().mine_to(FUND.ztest())); + let indexer = env.add_indexer(dev!(Indexer::Zainod, "../../Dockerfile").regtest()); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&taddr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + + let bal = indexer.get_taddress_balance(vec![taddr]).await?; + assert_eq!( + u64::try_from(i64::from(bal)).unwrap_or(0), + SEND_AMOUNT, + "get_taddress_balance must report the send amount" + ); + Ok(()) + } +} + +/// One-chain fetch-vs-state comparisons: each test stands up one zebrad on a shared +/// volume, a fetch zainod (`regtest`) and a state zainod (`tuning(ZainoTuning::State)`) +/// inline, and asserts `fetch == state` over the pods' gRPC / JSON-RPC surface. zebrad +/// only — the state pod reads the validator's own zebra db. +mod zebrad { + use super::*; + + /// `get_block_range` with no pools == requesting the shielded pools, + /// fetch==state, and the tip block holds the shielded coinbase + the send + /// with no transparent data. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn block_range_returns_default_pools() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(FUND.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + // fund_and_send(Orchard): one shielded coinbase note, then send it to the + // recipient's unified address and mine the send in. + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 1) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + faucet.send(&ua, SEND_AMOUNT).await?; + let end = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(end, READY).await?; + state.wait_for_block_num(end, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. The + // state pod must answer and the fetch pod must not, or both are reaching the one + // validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + // `PoolType` wire codes. This must equal `PoolTypeFilter::default()` — every + // shielded pool, ironwood included — or the default-vs-explicit checks below compare + // two different filters. + let shielded_pools = vec![2, 3, 4]; + let start = BlockHeight::from(1u32); + + let fetch_default = fetch.get_block_range(start, end).await?; + let fetch_shielded = fetch + .get_block_range_with_pools(start, end, shielded_pools.clone()) + .await?; + assert_eq!(fetch_default, fetch_shielded); + + let state_shielded = state + .get_block_range_with_pools(start, end, shielded_pools.clone()) + .await?; + let state_default = state.get_block_range(start, end).await?; + assert_eq!(state_default, state_shielded); + + assert_eq!(fetch_default, state_default); + + let compact_block = state_default.last().expect("non-empty range"); + assert_eq!(BlockHeight::from(compact_block.height as u32), end); + // The tip block holds the shielded coinbase and the send. + assert_eq!(compact_block.vtx.len(), 2); + assert_eq!(compact_block.vtx.last().expect("send tx").index, 1); + for tx in &compact_block.vtx { + assert!( + tx.vin.is_empty(), + "transparent data should be absent when no pool types are requested" + ); + assert!( + tx.vout.is_empty(), + "transparent data should be absent when no pool types are requested" + ); + } + Ok(()) + } + + /// With all pools requested the fetch and state indexers agree, and the tip + /// block carries the coinbase plus all three sends with their pool data. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn block_range_returns_all_pools() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(FUND.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + // Three shielded coinbase notes (one per send below), then one send to + // each pool's recipient address, mined into a single block. + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 3) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let mut txids = Vec::new(); + // NU6.3: the unified-address (Orchard-receiver) send emits Ironwood + // actions, so the compact block carries them under `ironwood_actions`. + for pool in [Pool::Transparent, Pool::Sapling, Pool::Ironwood] { + let addr = recipient.address(pool.ztest()).await?; + let txid = faucet + .send(&addr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("txid"); + txids.push(txid); + } + let end = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(end, READY).await?; + state.wait_for_block_num(end, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. The + // state pod must answer and the fetch pod must not, or both are reaching the one + // validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + // `PoolType` wire codes: transparent=1, sapling=2, orchard=3, ironwood=4. + let all_pools = vec![1, 2, 3, 4]; + let start = BlockHeight::from(1u32); + + let fetch_range = fetch + .get_block_range_with_pools(start, end, all_pools.clone()) + .await?; + let state_range = state + .get_block_range_with_pools(start, end, all_pools.clone()) + .await?; + assert_eq!(fetch_range, state_range); + + let compact_block = state_range.last().expect("non-empty range"); + assert_eq!(BlockHeight::from(compact_block.height as u32), end); + // coinbase + the three sends + assert_eq!(compact_block.vtx.len(), 4); + + assert_pool_present(compact_block, &txids[0], Pool::Transparent); + assert_pool_present(compact_block, &txids[1], Pool::Sapling); + assert_pool_present(compact_block, &txids[2], Pool::Ironwood); + // The unified-address send must carry no Orchard actions from NU6.3. + assert_pool_absent(compact_block, &txids[2], Pool::Orchard); + Ok(()) + } + + /// The fetch and state indexers agree on the tree state at the tip, and it is the tip's: + /// height and block hash pin the reply to the block the validator calls the tip, which a + /// default-valued or stale `TreeState` cannot satisfy. + #[ztest::qos::wallet(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn z_get_treestate_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(FUND.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 1) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let addr = recipient.address(Pool::Orchard.ztest()).await?; + faucet.send(&addr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. + // The state pod must answer and the fetch pod must not, or both are reaching the + // one validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let fetch_tree = fetch.get_tree_state(tip).await?; + assert_eq!( + fetch_tree.height, + u64::from(u32::from(tip)), + "the tree state must be the tip's" + ); + assert_eq!( + fetch_tree.hash, tip_hash, + "the tree state must name the block the validator calls the tip" + ); + assert_eq!(fetch_tree, state.get_tree_state(tip).await?); + Ok(()) + } + + /// Both indexers report no orchard subtree root. Zebra tracks roots at + /// `TRACKED_SUBTREE_HEIGHT = 16`, so the first one needs 2^16 note commitments and no + /// regtest fixture reaches a boundary — the emptiness is the assertion, and a synthesised + /// root would fail it. Coverage of a real boundary belongs in a `packages/zaino-state` + /// unit test over the mock chain source. + #[ztest::qos::wallet(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn z_get_subtrees_by_index_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(FUND.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 1) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let addr = recipient.address(Pool::Orchard.ztest()).await?; + faucet.send(&addr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. + // The state pod must answer and the fetch pod must not, or both are reaching the + // one validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let fetch_roots = fetch + .get_subtree_roots(0, ShieldedProtocol::Orchard, 0) + .await?; + assert!( + fetch_roots.is_empty(), + "a regtest chain completes no 2^16-note subtree, so no root can exist: \ + {fetch_roots:?}" + ); + assert_eq!( + fetch_roots, + state + .get_subtree_roots(0, ShieldedProtocol::Orchard, 0) + .await?, + ); + Ok(()) + } + + /// The fetch and state indexers serve the same whole `getrawtransaction` reply for the + /// send, and its `hex` is the transaction the validator holds — the validator is the + /// oracle, so a reply both indexers derived the same wrong way still fails. + #[ztest::qos::wallet(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_raw_transaction_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(FUND.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 1) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let addr = recipient.address(Pool::Orchard.ztest()).await?; + let txid = faucet + .send(&addr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. + // The state pod must answer and the fetch pod must not, or both are reaching the + // one validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let vrpc = validator.json_rpc().await?; + let raw_hex = vrpc + .call_value("getrawtransaction", json!([txid.to_string()])) + .await?; + let raw_hex = raw_hex + .as_str() + .context("getrawtransaction must return a hex string at verbosity 0")?; + + let params = serde_json::json!([txid.to_string(), 1]); + let fetch_tx = fetch + .json_rpc() + .await? + .call_value("getrawtransaction", params.clone()) + .await?; + assert_eq!( + fetch_tx.get("hex").and_then(serde_json::Value::as_str), + Some(raw_hex), + "zaino served different bytes than the validator holds for {txid}" + ); + assert_eq!( + fetch_tx, + state + .json_rpc() + .await? + .call_value("getrawtransaction", params) + .await?, + ); + Ok(()) + } + + /// `getaddresstxids` over the recipient's taddr returns exactly the send, on both ingest + /// paths. The expected list is the txid the wallet reported before either indexer was + /// asked, so an empty or over-full answer cannot pass. + #[ztest::qos::wallet(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_address_tx_ids_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(FUND.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 1) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. + // The state pod must answer and the fetch pod must not, or both are reaching the + // one validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let height = u32::from(tip); + let fetch_txids = fetch + .json_rpc() + .await? + .call_value( + "getaddresstxids", + serde_json::json!([{ "addresses": [&taddr], "start": height, "end": height }]), + ) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + assert_eq!( + fetch_txids, + vec![txid.to_string()], + "the send is the only transaction touching the recipient taddr at {height}" + ); + assert_eq!( + fetch_txids, + state + .json_rpc() + .await? + .call_value( + "getaddresstxids", + serde_json::json!([{ "addresses": [&taddr], "start": height, "end": height }]), + ) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default() + ); + Ok(()) + } + + /// `getaddressutxos` over the recipient's taddr returns exactly the send's output, on + /// both ingest paths, over both the JSON-RPC and gRPC surfaces. Whole replies are + /// compared: `txid` alone would pass on a wrong value, height or script. + #[ztest::qos::wallet(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_address_utxos_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(FUND.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 1) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. + // The state pod must answer and the fetch pod must not, or both are reaching the + // one validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let params = serde_json::json!([{ "addresses": [&taddr] }]); + let fetch_json = fetch + .json_rpc() + .await? + .call_value("getaddressutxos", params.clone()) + .await?; + assert_eq!( + fetch_json.as_array().map(Vec::len), + Some(1), + "the send leaves exactly one utxo at the recipient taddr: {fetch_json}" + ); + assert_eq!( + fetch_json + .pointer("/0/satoshis") + .and_then(serde_json::Value::as_u64), + Some(SEND_AMOUNT), + "the utxo must carry the send amount: {fetch_json}" + ); + assert_eq!( + fetch_json, + state + .json_rpc() + .await? + .call_value("getaddressutxos", params) + .await?, + ); + + let zero = BlockHeight::from(0u32); + let fetch_utxos = fetch + .get_address_utxos(vec![taddr.clone()], zero, 0) + .await?; + assert_eq!(fetch_utxos.len(), 1); + assert_eq!( + fetch_utxos[0].txid, + txid.as_ref().to_vec(), + "the only utxo must be the send's output" + ); + assert_eq!( + fetch_utxos, + state.get_address_utxos(vec![taddr], zero, 0).await? + ); + Ok(()) + } + + /// Both indexers mirror the *validator's* mempool while two sends sit unmined. + /// + /// Not an ingest-path comparison: the mempool has no read-state implementation at all + /// (`GetMempoolTxids` is JSON-RPC-only on both backends), so the two pods would agree by + /// construction. The validator is the oracle instead, and the two broadcast txids keep + /// `[] == []` from passing. + #[ztest::qos::wallet(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_raw_mempool_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(FUND.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 2) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let ua = recipient.address(Pool::Orchard.ztest()).await?; + let t_txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let u_txid = faucet + .send(&ua, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + + let vrpc = validator.json_rpc().await?; + let want = [t_txid.to_string(), u_txid.to_string()]; + let frpc = fetch.json_rpc().await?; + let srpc = state.json_rpc().await?; + // Zaino's mempool is a polled mirror (500 ms cadence), so each pod agrees with the + // validator only eventually; `want` is the non-vacuity probe — a send that was built + // but never relayed leaves every side empty and equal. + let deadline = tokio::time::Instant::now() + READY; + let (fetch_mempool, state_mempool) = loop { + let mut sets = Vec::new(); + for rpc in [&vrpc, &frpc, &srpc] { + let mut txids = rpc + .call_value("getrawmempool", serde_json::json!([])) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + txids.sort(); + sets.push(txids); + } + if sets[0] == sets[1] && sets[0] == sets[2] && want.iter().all(|t| sets[0].contains(t)) + { + break (sets[1].clone(), sets[2].clone()); + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "the indexer mempools never converged on the validator's {sets:?} holding \ + {want:?}" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + }; + + assert_eq!( + fetch_mempool.len(), + want.len(), + "the mirror must hold the two broadcast txs and nothing else: {fetch_mempool:?}" + ); + assert_eq!(fetch_mempool, state_mempool); + Ok(()) + } + + /// `GetTaddressTxids` over the recipient's taddr returns exactly the send's transaction, + /// on both ingest paths. The reply carries whole raw transactions, so comparing it + /// covers the bytes and not merely the count. + #[ztest::qos::wallet(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_address_transactions_regtest() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(FUND.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 1) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + let txid = faucet + .send(&taddr, SEND_AMOUNT) + .await? + .into_iter() + .next() + .expect("send returns a txid"); + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. + // The state pod must answer and the fetch pod must not, or both are reaching the + // one validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let raw_hex = validator + .json_rpc() + .await? + .call_value("getrawtransaction", json!([txid.to_string()])) + .await?; + let raw_hex = raw_hex + .as_str() + .context("getrawtransaction must return a hex string at verbosity 0")?; + + let fetch_txs = fetch.get_taddress_txids(taddr.clone(), tip, tip).await?; + assert_eq!( + fetch_txs.len(), + 1, + "only the send pays the recipient taddr in block {tip:?}" + ); + assert_eq!( + zaino_testutils::hex::encode(&fetch_txs[0].data), + raw_hex, + "the served transaction must be the one the validator holds" + ); + assert_eq!(fetch_txs, state.get_taddress_txids(taddr, tip, tip).await?); + Ok(()) + } + + /// With transparent mining every compact-block tx carries a transparent vout, so each + /// vout's `script_pub_key` is non-empty — and both ingest paths build the same range. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn transparent_data_in_compact_block() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Transparent.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let chain_height = validator.generate_blocks(5).await?; + fetch.wait_for_block_num(chain_height, READY).await?; + state.wait_for_block_num(chain_height, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. The + // state pod must answer and the fetch pod must not, or both are reaching the one + // validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + // `PoolType` wire codes: transparent=1, sapling=2, orchard=3, ironwood=4. + let all_pools = vec![1, 2, 3, 4]; + // Zaino cannot serve the non-standard genesis coinbase script in compact blocks, so + // this starts at height 1, not 0 (zingolabs/zaino#818). + let start = BlockHeight::from(1u32); + let state_range = state + .get_block_range_with_pools(start, chain_height, all_pools.clone()) + .await?; + assert_eq!( + state_range.len(), + u32::from(chain_height) as usize, + "the range must serve every height in [1, {chain_height:?}]" + ); + assert_eq!( + state_range, + fetch + .get_block_range_with_pools(start, chain_height, all_pools) + .await? + ); + for cb in state_range { + for tx in cb.vtx { + assert!( + !tx.vout + .first() + .expect("transparent vout present") + .script_pub_key + .is_empty(), + "each tx's transparent output must carry a script pub key" + ); + } + } + Ok(()) + } + + /// The fetch and state indexers agree on `getaddresstxids` over the faucet's coinbase + /// taddr. Under `mine_to(Transparent)` the faucet account is the miner address, so its + /// taddr holds one coinbase per block: the txid count must equal the height span, which + /// an empty or truncated answer cannot satisfy. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_taddress_txids_faucet_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Transparent.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet.faucet(&validator, &fetch).await?; + let faucet_taddr = faucet.address(Pool::Transparent.ztest()).await?; + let tip = validator.generate_blocks(100).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. The + // state pod must answer and the fetch pod must not, or both are reaching the one + // validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let fetch_txids = fetch + .json_rpc() + .await? + .call_value( + "getaddresstxids", + serde_json::json!([{ "addresses": [&faucet_taddr], "start": 2, "end": 5 }]), + ) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + let state_txids = state + .json_rpc() + .await? + .call_value( + "getaddresstxids", + serde_json::json!([{ "addresses": [&faucet_taddr], "start": 2, "end": 5 }]), + ) + .await? + .as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + assert_eq!( + fetch_txids.len(), + 4, + "every block in [2, 5] pays one coinbase to the miner taddr: {fetch_txids:?}" + ); + assert_eq!(fetch_txids, state_txids); + Ok(()) + } + + /// The fetch and state indexers agree on the transparent balance of the faucet's + /// coinbase taddr, and that balance is the sum of the utxos each reports for the same + /// address — a cross-check no zero-valued or truncated answer survives. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_taddress_balance_faucet_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Transparent.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet.faucet(&validator, &fetch).await?; + let faucet_taddr = faucet.address(Pool::Transparent.ztest()).await?; + let tip = validator.generate_blocks(5).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. The + // state pod must answer and the fetch pod must not, or both are reaching the one + // validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let utxos = fetch + .json_rpc() + .await? + .call_value( + "getaddressutxos", + serde_json::json!([{ "addresses": [&faucet_taddr] }]), + ) + .await?; + let utxo_total: i64 = utxos + .as_array() + .context("getaddressutxos must return an array")? + .iter() + .filter_map(|u| u.get("satoshis").and_then(serde_json::Value::as_i64)) + .sum(); + assert!(utxo_total > 0, "faucet taddr must hold coinbase value"); + + let fetch_bal = fetch + .get_taddress_balance(vec![faucet_taddr.clone()]) + .await?; + let state_bal = state.get_taddress_balance(vec![faucet_taddr]).await?; + assert_eq!( + i64::from(fetch_bal), + utxo_total, + "the balance must be the sum of the utxos reported for the same address" + ); + assert_eq!(i64::from(fetch_bal), i64::from(state_bal)); + Ok(()) + } + + /// The fetch and state indexers agree on `GetAddressUtxos` over the faucet's coinbase + /// taddr. `max_entries = 3` over a chain that pays the miner in every block must return + /// exactly three utxos, so a truncated or empty reply cannot pass. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_address_utxos_faucet_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Transparent.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet.faucet(&validator, &fetch).await?; + let faucet_taddr = faucet.address(Pool::Transparent.ztest()).await?; + let tip = validator.generate_blocks(5).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. The + // state pod must answer and the fetch pod must not, or both are reaching the one + // validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let start = BlockHeight::from(2u32); + let fetch_utxos = fetch + .get_address_utxos(vec![faucet_taddr.clone()], start, 3) + .await?; + assert_eq!( + fetch_utxos.len(), + 3, + "every block from {start:?} pays the miner taddr, so `max_entries` binds" + ); + assert_eq!( + fetch_utxos, + state + .get_address_utxos(vec![faucet_taddr], start, 3) + .await? + ); + Ok(()) + } + + /// The streamed utxos agree between the fetch and state indexers over the faucet's + /// coinbase taddr, and with the unary reply over the same arguments. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_address_utxos_stream_faucet_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Transparent.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet.faucet(&validator, &fetch).await?; + let faucet_taddr = faucet.address(Pool::Transparent.ztest()).await?; + let tip = validator.generate_blocks(5).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. The + // state pod must answer and the fetch pod must not, or both are reaching the one + // validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let start = BlockHeight::from(2u32); + let fetch_utxos = fetch + .get_address_utxos_stream(vec![faucet_taddr.clone()], start, 3) + .await?; + assert_eq!( + fetch_utxos.len(), + 3, + "every block from {start:?} pays the miner taddr, so `max_entries` binds" + ); + assert_eq!( + fetch_utxos, + fetch + .get_address_utxos(vec![faucet_taddr.clone()], start, 3) + .await?, + "the streamed and unary replies must agree over the same arguments" + ); + assert_eq!( + fetch_utxos, + state + .get_address_utxos_stream(vec![faucet_taddr], start, 3) + .await? + ); + Ok(()) + } + + /// The five `getaddressdeltas` request shapes: the bare-address form, the + /// multi-address union, the `chainInfo` form's named endpoints, an `end` past the + /// tip clamping down to it, and an address with no history answering empty. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn address_deltas() -> Result<()> { + // Valid-format regtest taddr that nothing on this chain ever pays. + const UNKNOWN_TADDR: &str = "tmVqEASZxBNKFTbmASZikGa5fPLkd68iJyx"; + + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Transparent.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + // Transparent coinbase: `funded_faucet_with_notes` matures it and shields it into + // Orchard, so the faucet taddr carries both the coinbase credits and the shield's + // debits by the time the send credits the recipient taddr. + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 1) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let faucet_taddr = faucet.address(Pool::Transparent.ztest()).await?; + let recipient_taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&recipient_taddr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + let tip = u32::from(tip); + + // zebrad implements no `getaddressdeltas`, so only a read-state can answer it. The + // state pod must answer and the fetch pod must not, or the assertions below are + // reading the validator over the one transport rather than zaino's read-state. + let srpc = state.json_rpc().await?; + let bare = srpc + .call_value("getaddressdeltas", serde_json::json!([&recipient_taddr])) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getaddressdeltas", serde_json::json!([&recipient_taddr])) + .await + .is_err(), + "the fetch indexer answered getaddressdeltas, which only a read-state can serve" + ); + + // The bare-address form answers the rangeless whole chain as a plain array. + let bare = bare + .as_array() + .context("the bare-address form answers a delta array")?; + assert_eq!( + bare.len(), + 1, + "coinbase pays the faucet taddr, so the send is the recipient taddr's only delta" + ); + assert_eq!(bare[0]["height"].as_u64(), Some(u64::from(tip))); + assert_eq!( + bare[0]["index"].as_u64(), + Some(0), + "the send's sole transparent output" + ); + assert_eq!(bare[0]["satoshis"].as_u64(), Some(SEND_AMOUNT)); + assert_eq!(bare[0]["address"].as_str(), Some(recipient_taddr.as_str())); + + let both = srpc + .call_value( + "getaddressdeltas", + serde_json::json!([{ + "addresses": [&faucet_taddr, &recipient_taddr], + "start": 0, + "end": tip, + }]), + ) + .await?; + let both = both + .as_array() + .context("a request without chainInfo answers a delta array")?; + assert!( + both.iter() + .any(|delta| delta["address"].as_str() == Some(faucet_taddr.as_str())) + && both + .iter() + .any(|delta| delta["address"].as_str() == Some(recipient_taddr.as_str())), + "a multi-address request unions every requested address's deltas" + ); + + let named = srpc + .call_value( + "getaddressdeltas", + serde_json::json!([{ + "addresses": [&faucet_taddr, &recipient_taddr], + "start": 1, + "end": tip, + "chainInfo": true, + }]), + ) + .await?; + assert_eq!(named["start"]["height"].as_u64(), Some(1)); + assert_eq!(named["end"]["height"].as_u64(), Some(u64::from(tip))); + assert!(!named["deltas"] + .as_array() + .context("the chainInfo form wraps its deltas")? + .is_empty()); + + let clamped = srpc + .call_value( + "getaddressdeltas", + serde_json::json!([{ + "addresses": [&faucet_taddr], + "start": 1, + "end": tip + 100, + "chainInfo": true, + }]), + ) + .await?; + assert_eq!( + clamped["end"]["height"].as_u64(), + Some(u64::from(tip)), + "an end past the tip is clamped to the tip, not rejected" + ); + + let unknown = srpc + .call_value( + "getaddressdeltas", + serde_json::json!([{ + "addresses": [UNKNOWN_TADDR], + "start": 1, + "end": tip, + "chainInfo": true, + }]), + ) + .await?; + assert_eq!( + unknown["deltas"], + serde_json::json!([]), + "an address with no history answers the chainInfo shape with no deltas" + ); + Ok(()) + } + + /// Regression coverage for AP-03 / Zellic #48500 + /// (`get_block_deltas_resolves_transparent_spend`): the State backend used to + /// silently drop every *non-coinbase transparent spend input* from + /// `getblockdeltas`, because Zebra's stateless verbosity-2 transaction object + /// leaves an input's value/address unset. The fix resolves each spend's + /// prevout and reads the spent output's value/address. + /// + /// The faucet pays the recipient a transparent output; the recipient then + /// shields it, producing a non-coinbase transparent input that references the + /// funding output. The spend block's `InputDelta` must be present and resolve + /// to the funding output's address and full (negated) value — pre-fix this + /// input was dropped and `inputs` was empty, so balances overstated funds. + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + async fn get_block_deltas_resolves_transparent_spend() -> Result<()> { + // The only transparent output in the funding block: coinbase pays the + // shielded pool and the faucet's change returns shielded, so the funding + // output is uniquely identifiable by its amount. + const FUNDING_AMOUNT: i64 = 250_000; + + // State-backend only: zebra serves no `getblockdeltas` RPC, so only the + // state backend — which synthesizes the deltas from a verbosity-2 block + // and resolves each spend's prevout via its `ReadStateService` — can + // answer it. There is no fetch path and no fetch-vs-state cross-check. The + // state zainod opens the validator's zebra-state DB as a RocksDB secondary + // over the shared volume, so the validator mounts the same `vol` + // (`.mount(&vol)`). + // + // Coinbase mines to Orchard. Orchard is invalid before NU5 (height 2) and + // the miner address pins the pool, so `funded_faucet_with_notes` warms the + // chain past NU5 before mining the faucet's notes. The coinbase is + // shielded, so the 250_000 transparent send is the funding block's only + // transparent output. + // + // zebra must link the same orchard 0.15 / zcash_protocol 0.10 as the + // wallet and miner to verify their proofs; an older 5.2.0 (orchard ~0.13) + // rejects them with "could not validate orchard proof". + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Orchard.ztest()) + .mount(&vol), + ); + let indexer = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + let irpc = indexer.json_rpc().await?; + + // One shielded coinbase note, then fund the recipient's transparent + // address with a non-coinbase transparent output. + let faucet = wallet + .funded_faucet_with_notes(&validator, &indexer, 1) + .await?; + let recipient = wallet.recipient(&validator, &indexer).await?; + let recipient_taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&recipient_taddr, FUNDING_AMOUNT as u64).await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + let funding_block_hash = irpc + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + + // The recipient confirms the received output and shields it; the + // shielding tx spends that output, producing the non-coinbase transparent + // input under test. + recipient.sync().await?; + recipient.shield().await?; + let tip = validator.generate_blocks(1).await?; + indexer.wait_for_block_num(tip, READY).await?; + let spend_block_hash = irpc + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + + // Locate the funding output (unique by amount) and capture its + // txid / index / address. + let funding = irpc + .call_value("getblockdeltas", serde_json::json!([funding_block_hash])) + .await?; + let funding_delta = funding + .get("deltas") + .and_then(serde_json::Value::as_array) + .unwrap_or(&Vec::new()) + .iter() + .find(|d| { + d.get("outputs") + .and_then(serde_json::Value::as_array) + .is_some_and(|outputs| { + outputs.iter().any(|o| { + o.get("satoshis").and_then(serde_json::Value::as_i64) + == Some(FUNDING_AMOUNT) + }) + }) + }) + .cloned() + .expect("funding tx paying the recipient should be in its block"); + let funding_output = funding_delta + .get("outputs") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .find(|o| o.get("satoshis").and_then(serde_json::Value::as_i64) == Some(FUNDING_AMOUNT)) + .expect("funding output paying the recipient should be present"); + let funding_txid = funding_delta + .get("txid") + .and_then(serde_json::Value::as_str) + .expect("delta txid") + .to_string(); + let funding_vout = funding_output + .get("index") + .and_then(serde_json::Value::as_u64) + .expect("output index"); + let funding_address = funding_output + .get("address") + .and_then(serde_json::Value::as_str) + .expect("output address") + .to_string(); + + // The spend's input must be present and resolved to the funding output's + // address and full value (negative — it is a debit). Pre-fix `inputs` was + // empty and this lookup would fail. + let spend = irpc + .call_value("getblockdeltas", serde_json::json!([spend_block_hash])) + .await?; + let input = spend + .get("deltas") + .and_then(serde_json::Value::as_array) + .unwrap_or(&Vec::new()) + .iter() + .flat_map(|d| { + d.get("inputs") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default() + }) + .find(|i| { + i.get("prevtxid").and_then(serde_json::Value::as_str) == Some(&funding_txid) + && i.get("prevout").and_then(serde_json::Value::as_u64) == Some(funding_vout) + }) + .expect("spend input referencing the funding output should be present"); + + assert_eq!( + input.get("address").and_then(serde_json::Value::as_str), + Some(funding_address.as_str()), + "input must resolve to the prevout's address" + ); + assert_eq!( + input.get("satoshis").and_then(serde_json::Value::as_i64), + Some(-FUNDING_AMOUNT), + "input must resolve to the prevout's full value, negated" + ); + Ok(()) + } + + /// A freshly mined block carries only its (shielded) coinbase transaction — + /// the coinbase input is skipped and `getblockdeltas` fabricates no + /// transparent input deltas. + #[ztest::qos::integration] + #[tokio::test(flavor = "multi_thread")] + async fn get_block_deltas_coinbase_only_block_has_no_inputs() -> Result<()> { + // State-backend only (cf. `get_block_deltas_resolves_transparent_spend`): + // zebra serves no `getblockdeltas` RPC, so only the synthesizing state + // backend can answer it. Coinbase mines to Orchard; see the sibling test + // for why the orchard version must match the wallet. This test has no + // faucet, so it mines the NU5 warmup block itself (height 1, pre-NU5 + // fallback coinbase) and inspects the height-2 block — the first true + // Orchard coinbase — which carries only its coinbase tx, so + // `getblockdeltas` fabricates no transparent inputs. + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Orchard.ztest()) + .mount(&vol), + ); + let indexer = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol), + ); + env.build().await?; + let irpc = indexer.json_rpc().await?; + + // Height 1 warms past NU5; height 2 is the Orchard coinbase-only block + // under test. + let tip = validator.generate_blocks(2).await?; + indexer.wait_for_block_num(tip, READY).await?; + let block_hash = irpc + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + + let deltas = irpc + .call_value("getblockdeltas", serde_json::json!([block_hash])) + .await?; + let all_empty = deltas + .get("deltas") + .and_then(serde_json::Value::as_array) + .unwrap_or(&Vec::new()) + .iter() + .all(|d| { + d.get("inputs") + .and_then(serde_json::Value::as_array) + .is_none_or(|inputs| inputs.is_empty()) + }); + assert!(all_empty, "a coinbase-only block must have no input deltas"); + Ok(()) + } + + /// The recipient taddr reports exactly the send on both ingest paths. Whole replies are + /// compared, so a divergent `received` fails alongside a divergent `balance`. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_address_balance_fetch_vs_state() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(FUND.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + let wallet = env.add_wallet(Wallet::librustzcash()); + env.build().await?; + + let faucet = wallet + .funded_faucet_with_notes(&validator, &fetch, 1) + .await?; + let recipient = wallet.recipient(&validator, &fetch).await?; + let taddr = recipient.address(Pool::Transparent.ztest()).await?; + faucet.send(&taddr, SEND_AMOUNT).await?; + let tip = validator.generate_blocks(1).await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. The + // state pod must answer and the fetch pod must not, or both are reaching the one + // validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + let params = serde_json::json!([{ "addresses": [taddr] }]); + let fetch_bal = fetch + .json_rpc() + .await? + .call_value("getaddressbalance", params.clone()) + .await?; + let state_bal = state + .json_rpc() + .await? + .call_value("getaddressbalance", params) + .await?; + assert_eq!( + fetch_bal.get("balance").and_then(serde_json::Value::as_u64), + Some(SEND_AMOUNT), + "getaddressbalance must report the send: {fetch_bal}" + ); + assert_eq!(fetch_bal, state_bal); + Ok(()) + } + + /// Draining [1, 106] on a 100-block chain yields the 100 available blocks + /// (fetch == state) and then errors rather than ending cleanly. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_block_range_out_of_range_upper_bound() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Transparent.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let height = u32::from(fetch.latest_block_height().await?); + let tip = validator + .generate_blocks(100u32.saturating_sub(height)) + .await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. The + // state pod must answer and the fetch pod must not, or both are reaching the one + // validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + // `PoolType` wire codes: transparent=1, sapling=2, orchard=3, ironwood=4. + let all_pools = vec![1, 2, 3, 4]; + let (start, end) = (BlockHeight::from(1u32), BlockHeight::from(106u32)); + let (fetch_blocks, fetch_errored) = fetch + .drain_block_range(start, end, all_pools.clone()) + .await?; + let (state_blocks, state_errored) = state + .drain_block_range(start, end, all_pools.clone()) + .await?; + + assert_eq!(fetch_blocks, state_blocks); + let compact_block = state_blocks.last().expect("non-empty range"); + assert_eq!( + compact_block.height, 100, + "the drain must stop at the tip, not at the requested end" + ); + assert_eq!(fetch_blocks.len(), 100); + assert!(state_errored, "state stream should terminate with an error"); + assert!(fetch_errored, "fetch stream should terminate with an error"); + Ok(()) + } + + /// Draining the inverted range [106, 1] yields no blocks (fetch == state, + /// both empty) and then errors rather than ending cleanly. + #[ztest::qos::integration(footprint = "3c/6Gi")] + #[tokio::test(flavor = "multi_thread")] + async fn get_block_range_out_of_range_lower_bound() -> Result<()> { + let mut env = TestEnv::builder().ready_timeout(READY); + let vol = env.shared_volume("zebra-db"); + let validator = env.add_validator( + Validator::zebrad("6.2.3") + .regtest() + .mine_to(Pool::Transparent.ztest()) + .mount(&vol), + ); + let fetch = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::Fetch) + .named("zaino-fetch"), + ); + let state = env.add_indexer( + dev!(Indexer::Zainod, "../../Dockerfile") + .regtest() + .tuning(ZainoTuning::State) + .mount(&vol) + .named("zaino-state"), + ); + env.build().await?; + + let height = u32::from(fetch.latest_block_height().await?); + let tip = validator + .generate_blocks(100u32.saturating_sub(height)) + .await?; + fetch.wait_for_block_num(tip, READY).await?; + state.wait_for_block_num(tip, READY).await?; + + // zebrad implements no `getblockdeltas`, so only a read-state can synthesise one. The + // state pod must answer and the fetch pod must not, or both are reaching the one + // validator over the one transport and every comparison below is an identity. + let tip_hash = validator + .json_rpc() + .await? + .call_value("getbestblockhash", serde_json::json!([])) + .await? + .as_str() + .context("getbestblockhash returns a hash string")? + .to_string(); + let deltas = serde_json::json!([&tip_hash]); + state + .json_rpc() + .await? + .call_value("getblockdeltas", deltas.clone()) + .await?; + anyhow::ensure!( + fetch + .json_rpc() + .await? + .call_value("getblockdeltas", deltas) + .await + .is_err(), + "the fetch indexer answered getblockdeltas, which only a read-state can \ + synthesise; this pod pair is not comparing two ingest paths" + ); + + // `PoolType` wire codes: transparent=1, sapling=2, orchard=3, ironwood=4. + let all_pools = vec![1, 2, 3, 4]; + let (start, end) = (BlockHeight::from(106u32), BlockHeight::from(1u32)); + let (fetch_blocks, fetch_errored) = fetch + .drain_block_range(start, end, all_pools.clone()) + .await?; + let (state_blocks, state_errored) = state + .drain_block_range(start, end, all_pools.clone()) + .await?; + + assert_eq!(fetch_blocks, state_blocks); + assert!( + fetch_blocks.is_empty(), + "a descending range starting past the tip serves nothing: {fetch_blocks:?}" + ); + assert!(state_errored, "state stream should terminate with an error"); + assert!(fetch_errored, "fetch stream should terminate with an error"); + Ok(()) + } +} diff --git a/live-tests/quick_test.txt b/live-tests/quick_test.txt deleted file mode 100644 index 91351f15..00000000 --- a/live-tests/quick_test.txt +++ /dev/null @@ -1,94 +0,0 @@ -chain_query_interface::get_block_range_zebrad -chain_query_interface::get_mempool_stream_fresh_snapshot_repeated_zebrad -chain_query_interface::get_subtree_roots_zebrad -chain_query_interface::zallet_like_steady_state_loop_zebrad -zcashd::get::address_balance -zcashd::get::address_tx_ids -zcashd::get::address_utxos -zcashd::get::block -zcashd::get::block_deltas -zcashd::get::block_header -zcashd::get::block_nullifiers -zcashd::get::block_object -zcashd::get::block_range_no_pool_type_returns_sapling_orchard -zcashd::get::block_range_returns_all_pools_when_requested -zcashd::get::block_raw -zcashd::get::difficulty -zcashd::get::get_network_sol_ps -zcashd::get::latest_block -zcashd::get::latest_tree_state -zcashd::get::lightd_info -zcashd::get::mining_info -zcashd::get::peer_info -zcashd::get::subtree_roots -zcashd::launch::regtest_no_cache -zcashd::validation::validate_address -zcashd::validation::z_validate_address -zebrad::get::best_blockhash -zebrad::get::block_count -zebrad::get::block_header -zebrad::get::block_nullifiers -zebrad::get::block_object -zebrad::get::block_range_no_pool_type_returns_sapling_orchard -zebrad::get::block_range_nullifiers -zebrad::get::block_range_returns_all_pools_when_requested -zebrad::get::block_subsidy -zebrad::get::difficulty -zebrad::get::get_network_sol_ps -zebrad::get::latest_block -zebrad::get::latest_tree_state -zebrad::get::lightd_info -zebrad::get::subtree_roots -zebrad::get::tree_state -zebrad::launch::regtest_no_cache -zebrad::validation::validate_address -zebrad::validation::z_validate_address -zcashd::zcash_indexer::check_info_with_cookie -zcashd::zcash_indexer::get_address_tx_ids -zcashd::zcash_indexer::get_best_blockhash -zcashd::zcash_indexer::get_block_count -zcashd::zcash_indexer::get_block_deltas -zcashd::zcash_indexer::get_block_header -zcashd::zcash_indexer::get_peer_info -zcashd::zcash_indexer::validate_address -zcashd::zcash_indexer::z_get_address_balance -zcashd::zcash_indexer::z_get_address_utxos -zcashd::zcash_indexer::z_get_block -zcashd::zcash_indexer::z_validate_address -zebra::check_info::regtest_no_cache -zebra::check_info::state_service_chaintip_update_subscriber -zebra::get::address_tx_ids_regtest -zebra::get::address_utxos -zebra::get::best_blockhash -zebra::get::block_count -zebra::get::block_object_regtest -zebra::get::block_raw_regtest -zebra::get::difficulty -zebra::get::get_network_sol_ps -zebra::get::mining_info -zebra::get::peer_info -zebra::get::taddress_transactions_regtest -zebra::get::z::get_block_range_default_request_returns_all_pools_regtest -zebra::get::z::get_block_range_default_request_returns_no_t_data_regtest -zebra::get::z::get_block_range_out_of_range_test_lower_bound_regtest -zebra::get::z::get_block_range_out_of_range_test_upper_bound_regtest -zebra::get::z::treestate_regtest -zebra::get::z::z_validate_address -zebra::lightwallet_indexer::get_address_utxos -zebra::lightwallet_indexer::get_address_utxos_stream -zebra::lightwallet_indexer::get_block -zebra::lightwallet_indexer::get_block_header -zebra::lightwallet_indexer::get_block_range_full -zebra::lightwallet_indexer::get_block_range_nullifiers -zebra::lightwallet_indexer::get_latest_block -zebra::lightwallet_indexer::get_latest_tree_state -zebra::lightwallet_indexer::get_subtree_roots -zebra::lightwallet_indexer::get_taddress_balance -zebra::lightwallet_indexer::get_taddress_txids -zebra::lightwallet_indexer::get_transaction -zebra::lightwallet_indexer::get_transparent_data_from_compact_block_when_requested -zebra::lightwallet_indexer::get_tree_state -pre_v4_txs_parsing -zcashd::connect_to_node_get_info -zebrad::fetch_service::connect_to_node_get_info -zebrad::state_service::connect_to_node_get_info diff --git a/live-tests/test_binaries/bins/.gitinclude b/live-tests/test_binaries/bins/.gitinclude deleted file mode 100644 index e69de29b..00000000 diff --git a/live-tests/test_environment/Containerfile b/live-tests/test_environment/Containerfile index a6a19c7f..8f29c1f3 100644 --- a/live-tests/test_environment/Containerfile +++ b/live-tests/test_environment/Containerfile @@ -1,123 +1,17 @@ # syntax=docker/dockerfile:1.4 -# Begin storing hash-object for this file +# +# The zaino CI build environment: a Rust toolchain image (protoc, RocksDB, +# cargo-nextest) used as the `container:` for the GitHub Actions jobs. It ships +# **no** validator binaries — the live-test suites get their validators from the +# ztest Kubernetes harness, not from this image. See docs/testing.md. -######################## -# === GLOBAL ARGS === -######################## ARG RUST_VERSION -ARG ZCASH_VERSION -ARG ZEBRA_VERSION -ARG DEVTOOL_VERSION ARG UID=1000 ARG GID=${UID} ARG USER=container_user ARG HOME="/home/container_user" ARG CARGO_HOME="/usr/local/cargo" -######################## -# === DOWNLOADERS === -######################## - -FROM docker.io/zfnd/zebra:${ZEBRA_VERSION} AS zebra-downloader -FROM docker.io/zodlinc/zcash:v${ZCASH_VERSION} AS zcashd-downloader - - -######################## -# === BUILDERS === -######################## - -FROM docker.io/library/rust:${RUST_VERSION}-trixie AS zebra-builder - -ARG ZEBRA_VERSION -# Git ref resolved from ZEBRA_VERSION by the workbench `get-zebra-git-ref` -# bin: ZEBRA_VERSION is the Docker Hub tag (bare, e.g. "6.0.0-rc.0") while -# zebra's git release tags carry a `v` prefix. Both build entry points -# (build-image.sh, build-n-push-ci-image.yaml) resolve and pass it; the -# bare-ZEBRA_VERSION fallback only serves hand-run builds that pin a branch -# or SHA. -ARG ZEBRA_GIT_REF -# Versions pinned (DL3008) to the candidates in rust:1.95.0-trixie; bump -# together with the base image (query with `apt-cache policy `). -# --no-install-recommends (DL3015): ca-certificates is already present in the -# rust base, and the dropped recommends are llvm/dev/32-bit extras that the -# cargo build does not need. -RUN apt-get update && apt-get install -y --no-install-recommends \ - git=1:2.47.3-0+deb13u1 \ - clang=1:19.0-63 \ - cmake=3.31.6-2 \ - pkg-config=1.8.1-4 \ - protobuf-compiler=3.21.12-11+deb13u1 \ - libstdc++6=14.2.0-19 -WORKDIR /zebra -# Single RUN (DL3059): clone, checkout, build, and stage the binary. -RUN git clone https://github.com/ZcashFoundation/zebra . \ - && git checkout "${ZEBRA_GIT_REF:-$ZEBRA_VERSION}" \ - && cargo build --release --bin zebrad \ - && cp target/release/zebrad /tmp/zebrad - -FROM docker.io/library/debian:trixie AS zcashd-builder - -ARG ZCASH_VERSION -# Versions pinned (DL3008) to the candidates in debian:trixie; bump together -# with the base image (query with `apt-cache policy `). -# --no-install-recommends (DL3015): ca-certificates and netbase are re-added -# explicitly because the zcash source build clones and downloads over HTTPS; -# they were previously pulled in only as apt recommends. -RUN apt-get update && apt-get install -y --no-install-recommends \ - git=1:2.47.3-0+deb13u1 \ - build-essential=12.12 \ - pkg-config=1.8.1-4 \ - automake=1:1.17-4 \ - autoconf=2.72-3.1 \ - libtool=2.5.4-4 \ - bsdmainutils=12.1.8 \ - curl=8.14.1-2+deb13u4 \ - ca-certificates=20250419 \ - netbase=6.5 - -WORKDIR /zcash -# Single RUN (DL3059): clone, fetch params, build, and stage the binaries. -RUN git clone --depth 1 --branch "v${ZCASH_VERSION}" \ - https://github.com/zcash/zcash . \ - && ./zcutil/fetch-params.sh \ - && ./zcutil/build.sh -j"$(nproc)" \ - && cp src/zcashd src/zcash-cli /tmp/ - -FROM docker.io/library/rust:${RUST_VERSION}-trixie AS devtool-builder - -ARG DEVTOOL_VERSION -# Resolved commit SHA of DEVTOOL_VERSION (a branch, hence moving). It seeds -# the cache key of the build RUN below: a branch name alone never changes, so -# without this the layer is reused even after the branch advances, baking a -# stale binary. build-image.sh resolves it via `git ls-remote`; empty (e.g. -# DEVTOOL_VERSION already a SHA) falls back to checking out DEVTOOL_VERSION. -ARG DEVTOOL_REV -# Versions pinned (DL3008) to the candidates in rust:1.95.0-trixie; bump -# together with the base image (query with `apt-cache policy `). -# git/pkg-config as for zebra-builder; protobuf-compiler for -# tonic-build; libsqlite3-dev for the zcash_client_sqlite wallet database. -RUN apt-get update && apt-get install -y --no-install-recommends \ - git=1:2.47.3-0+deb13u1 \ - pkg-config=1.8.1-4 \ - protobuf-compiler=3.21.12-11+deb13u1 \ - libsqlite3-dev=3.46.1-7+deb13u1 -WORKDIR /devtool -# Single RUN (DL3059): clone, checkout, build, and stage the binary. The -# image's default toolchain builds it (the source tree's -# rust-toolchain.toml is removed so rustup does not fetch another one). -# `regtest_support` is the feature e2e' regtest clients require. -# Checking out the resolved SHA (falling back to the branch) makes this layer -# rebuild whenever DEVTOOL_REV changes. -RUN git clone https://github.com/zingolabs/zcash-devtool . \ - && git checkout "${DEVTOOL_REV:-$DEVTOOL_VERSION}" \ - && rm -f rust-toolchain.toml \ - && cargo build --release --features regtest_support \ - && cp target/release/zcash-devtool /tmp/zcash-devtool - -######################## -# === BASE RUNTIME IMAGE === -######################## - FROM docker.io/library/rust:${RUST_VERSION}-trixie AS base ARG RUST_VERSION @@ -133,7 +27,6 @@ ENV PATH="${CARGO_HOME}/bin:${PATH}" ENV PROTOC=/usr/bin/protoc ENV ROCKSDB_LIB_DIR=/usr/lib/x86_64-linux-gnu - LABEL maintainer="nachog00" LABEL org.zaino.test-artifacts.versions.rust="${RUST_VERSION}" @@ -151,8 +44,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Create container_user. The GID may already belong to a base-image group # (e.g. on macOS hosts the caller's primary group is `staff`, GID 20, which # collides with Debian's `dialout`); `--non-unique` lets the `container_user` -# group share that GID so later `--chown=container_user:container_user` COPYs -# still resolve the group by name. +# group share that GID so later name-based ownership still resolves. RUN groupadd --non-unique --gid ${GID} ${USER} && \ useradd --uid ${UID} --gid ${GID} --home-dir ${HOME} --create-home ${USER} @@ -161,86 +53,19 @@ RUN groupadd --non-unique --gid ${GID} ${USER} && \ # toolchain under a point-release identity (e.g. "1.92.0-x86_64-unknown-linux-gnu") # that does not match rust-toolchain.toml's channel-style `channel = "1.92"` — # without this line rustup would re-download the whole toolchain on every -# container start. RUST_VERSION is the canonical source from -# .env.testing-artifacts, also used in the FROM clause above. +# container start. RUST_VERSION is the canonical source, from rust-toolchain.toml +# via the workbench `get-rust-version` bin, also used in the FROM clause above. RUN rustup toolchain install ${RUST_VERSION} --profile minimal \ --component rustfmt --component clippy && \ rustup default ${RUST_VERSION} -# Create user-owned runtime directories -RUN mkdir -p ${HOME}/artifacts ${HOME}/bin ${CARGO_HOME} && \ +RUN mkdir -p ${CARGO_HOME} && \ chown -R ${UID}:${GID} ${HOME} ${CARGO_HOME} -# Copy and prepare entrypoint script -COPY --chown=${UID}:${GID} --chmod=0755 entrypoint.sh ${HOME}/bin/entrypoint.sh - -WORKDIR ${HOME}/zaino USER ${USER} -######################## -# === FINAL TARGETS === -######################## +# cargo-nextest is the workspace test runner CI drives (`cargo nextest +# archive`/`run`), installed as container_user into the user-writable CARGO_HOME. +RUN cargo install cargo-nextest --locked --root "${CARGO_HOME}" -FROM base AS final-prebuilt -ARG ZCASH_VERSION -ARG ZEBRA_VERSION -ARG DEVTOOL_VERSION -LABEL org.zaino.test-artifacts.versions.zcashd="${ZCASH_VERSION}" -LABEL org.zaino.test-artifacts.versions.zebra="${ZEBRA_VERSION}" -LABEL org.zaino.test-artifacts.versions.devtool="${DEVTOOL_VERSION}" -COPY --chown=container_user:container_user --chmod=0755 --from=zcashd-downloader /usr/bin/zcashd /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=zcashd-downloader /usr/bin/zcash-cli /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=zebra-downloader /usr/local/bin/zebrad /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=devtool-builder /tmp/zcash-devtool /home/container_user/artifacts/ -RUN cargo install cargo-nextest --locked --verbose --root "${CARGO_HOME}" - -ENTRYPOINT ["/home/container_user/bin/entrypoint.sh"] -CMD ["/bin/bash"] - -FROM base AS final-zcashd-source -ARG ZCASH_VERSION -ARG ZEBRA_VERSION -ARG DEVTOOL_VERSION -LABEL org.zaino.test-artifacts.versions.zcashd="${ZCASH_VERSION}" -LABEL org.zaino.test-artifacts.versions.zebra="${ZEBRA_VERSION}" -LABEL org.zaino.test-artifacts.versions.devtool="${DEVTOOL_VERSION}" -COPY --chown=container_user:container_user --chmod=0755 --from=zcashd-builder /tmp/zcashd /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=zcashd-builder /tmp/zcash-cli /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=zebra-downloader /usr/local/bin/zebrad /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=devtool-builder /tmp/zcash-devtool /home/container_user/artifacts/ -RUN cargo install cargo-nextest --locked --verbose --root "${CARGO_HOME}" - -ENTRYPOINT ["/home/container_user/bin/entrypoint.sh"] -CMD ["/bin/bash"] - -FROM base AS final-zebrad-source -ARG ZCASH_VERSION -ARG ZEBRA_VERSION -ARG DEVTOOL_VERSION -LABEL org.zaino.test-artifacts.versions.zcashd="${ZCASH_VERSION}" -LABEL org.zaino.test-artifacts.versions.zebra="${ZEBRA_VERSION}" -LABEL org.zaino.test-artifacts.versions.devtool="${DEVTOOL_VERSION}" -COPY --chown=container_user:container_user --chmod=0755 --from=zcashd-downloader /usr/bin/zcashd /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=zcashd-downloader /usr/bin/zcash-cli /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=zebra-builder /tmp/zebrad /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=devtool-builder /tmp/zcash-devtool /home/container_user/artifacts/ -RUN cargo install cargo-nextest --locked --verbose --root "${CARGO_HOME}" - -ENTRYPOINT ["/home/container_user/bin/entrypoint.sh"] -CMD ["/bin/bash"] - -FROM base AS final-all-source -ARG ZCASH_VERSION -ARG ZEBRA_VERSION -ARG DEVTOOL_VERSION -LABEL org.zaino.test-artifacts.versions.zcashd="${ZCASH_VERSION}" -LABEL org.zaino.test-artifacts.versions.zebra="${ZEBRA_VERSION}" -LABEL org.zaino.test-artifacts.versions.devtool="${DEVTOOL_VERSION}" -COPY --chown=container_user:container_user --chmod=0755 --from=zcashd-builder /tmp/zcashd /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=zcashd-builder /tmp/zcash-cli /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=zebra-builder /tmp/zebrad /home/container_user/artifacts/ -COPY --chown=container_user:container_user --chmod=0755 --from=devtool-builder /tmp/zcash-devtool /home/container_user/artifacts/ -RUN cargo install cargo-nextest --locked --verbose --root "${CARGO_HOME}" - -ENTRYPOINT ["/home/container_user/bin/entrypoint.sh"] -CMD ["/bin/bash"] +WORKDIR ${HOME}/zaino diff --git a/live-tests/test_environment/entrypoint.sh b/live-tests/test_environment/entrypoint.sh deleted file mode 100755 index 09fe0852..00000000 --- a/live-tests/test_environment/entrypoint.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/bash -set -e - -# Usage: link_to_binaries.sh [REPO_ROOT] [ZCASHD_PATH] [ZEBRAD_PATH] [ZCASH_CLI_PATH] [DEVTOOL_PATH] -# -# Arguments: -# REPO_ROOT - Repository root directory (default: /home/container_user/zaino) -# ZCASHD_PATH - Path to zcashd binary (default: /home/container_user/artifacts/zcashd) -# ZEBRAD_PATH - Path to zebrad binary (default: /home/container_user/artifacts/zebrad) -# ZCASH_CLI_PATH - Path to zcash-cli binary (default: /home/container_user/artifacts/zcash-cli) -# DEVTOOL_PATH - Path to zcash-devtool binary (default: /home/container_user/artifacts/zcash-devtool) - -# Check if this script is being called with arguments that are actually a command to execute -# If the first argument looks like a command (doesn't start with / or .), then skip the setup -if [ $# -gt 0 ] && [[ ! "$1" =~ ^[/\.] ]]; then - # This is a command, not a path argument - skip setup and execute - exec "$@" -fi - -# Use provided arguments or defaults -REPO_ROOT="${1:-/home/container_user/zaino}" -ZCASHD_PATH="${2:-/home/container_user/artifacts/zcashd}" -ZEBRAD_PATH="${3:-/home/container_user/artifacts/zebrad}" -ZCASH_CLI_PATH="${4:-/home/container_user/artifacts/zcash-cli}" -DEVTOOL_PATH="${5:-/home/container_user/artifacts/zcash-devtool}" - - -# Check if live-tests/test_binaries/bins directory exists and create symlinks if binaries are missing -BINS_DIR="${REPO_ROOT}/live-tests/test_binaries/bins" - -# Create the bins directory if it doesn't exist -if [ ! -d "$BINS_DIR" ]; then - echo "Creating $BINS_DIR directory..." - mkdir -p "$BINS_DIR" -fi - -echo "Checking for test binaries in $BINS_DIR..." - -# Check and create symlink for zcashd -if [ ! -f "$BINS_DIR/zcashd" ] && [ ! -L "$BINS_DIR/zcashd" ]; then - echo "zcashd not found in $BINS_DIR, creating symlink..." - ln -s "$ZCASHD_PATH" "$BINS_DIR/zcashd" -fi - -# Check and create symlink for zebrad -if [ ! -f "$BINS_DIR/zebrad" ] && [ ! -L "$BINS_DIR/zebrad" ]; then - echo "zebrad not found in $BINS_DIR, creating symlink..." - ln -s "$ZEBRAD_PATH" "$BINS_DIR/zebrad" -fi - -# Check and create symlink for zcash-cli -if [ ! -f "$BINS_DIR/zcash-cli" ] && [ ! -L "$BINS_DIR/zcash-cli" ]; then - echo "zcash-cli not found in $BINS_DIR, creating symlink..." - ln -s "$ZCASH_CLI_PATH" "$BINS_DIR/zcash-cli" -fi - -# Check and create symlink for zcash-devtool -if [ ! -f "$BINS_DIR/zcash-devtool" ] && [ ! -L "$BINS_DIR/zcash-devtool" ]; then - echo "zcash-devtool not found in $BINS_DIR, creating symlink..." - ln -s "$DEVTOOL_PATH" "$BINS_DIR/zcash-devtool" -fi - -echo "Binary setup complete. Contents of $BINS_DIR:" -ls -la "$BINS_DIR" \ No newline at end of file diff --git a/live-tests/zaino-testutils/Cargo.toml b/live-tests/zaino-testutils/Cargo.toml index 09df520a..7da416d4 100644 --- a/live-tests/zaino-testutils/Cargo.toml +++ b/live-tests/zaino-testutils/Cargo.toml @@ -1,76 +1,39 @@ [package] name = "zaino-testutils" -description = "Crate containing Zaino test specific functionality." +description = "Shared helpers for Zaino live tests running on the ztest Kubernetes harness." authors = { workspace = true } repository = { workspace = true } homepage = { workspace = true } edition = { workspace = true } license = { workspace = true } -version = "0.2.0" +version = { workspace = true } publish = false -[features] -default = [] - -# Support for the zcashd test validator. Opt-in (default off, docs/adr/0005); being deprecated. Gates -# the zcashd-specific test infra (ValidatorKind::Zcashd, the Zcashd validator -# launchers, ZcashdDualFetchServices, the zcashd smoke-test module) and -# forwards to the zaino libs. See -# docs/adr/0001-zcashd-support-feature-gate.md. -zcashd_support = [ - "zaino-serve/zcashd_support", - "zainod/zcashd_support", -] - -# **Experimental and alpha features** -# Exposes the **complete** set of experimental / alpha features currently implemented in Zaino. -experimental_features = ["transparent_address_history_experimental"] - -# Activates transparent address history capability in zaino -# -# NOTE: currently this is only implemented in the finalised state. -transparent_address_history_experimental = [ - "zaino-state/transparent_address_history_experimental", - "zaino-serve/transparent_address_history_experimental", - "zainod/transparent_address_history_experimental" -] - [dependencies] -# Zaino -zaino-state = { workspace = true } -zaino-serve.workspace = true -zaino-common.workspace = true -zaino-proto = { workspace = true } -# test_dependencies enables Indexer::launch_inner_with_listeners (and forwards -# zaino-serve/test_dependencies) so the harness can serve on pre-bound sockets. -zainod = { workspace = true, features = ["test_dependencies"] } - -# Librustzcash. local-consensus is test-only (regtest params); kept off the -# root workspace entry so prod resolution is unchanged. -zcash_protocol = { workspace = true, features = ["local-consensus"] } - -# Zebra +# The k8s test harness. Owns validator/indexer/wallet lifecycle and exposes +# typed RPC handles back to test code (replaces zcash_local_net's subprocess +# launcher and Zaino's in-process service subscribers). +ztest = { workspace = true } + +# `anyhow::Result` on the parity helpers; JSON-RPC response comparison in the +# `json` / `rpc` parity helpers. +anyhow = { workspace = true } +serde_json = { workspace = true } + +# `legacy_parser` only. It is the independent second parser the transaction +# vectors compare against, so it renders into the same compact-format types the +# server serves. `default-features = false` keeps the `heavy` feature — and with +# it zebra-state and rocksdb — out of the live-test dependency graph. +zaino-proto = { workspace = true, default-features = false } zebra-chain = { workspace = true } -zebra-state = { workspace = true } -zebra-rpc = { workspace = true } - -# Zingo-infra -zcash_local_net = { workspace = true } +prost = { workspace = true } +thiserror = { workspace = true } -# Miscellaneous -futures = { workspace = true } -http = { workspace = true } -once_cell = { workspace = true } +# The hex codec behind the `hex` module. +hex = "0.4" +# Polling clock behind the `finalised` gate. tokio = { workspace = true } -tracing.workspace = true -zaino-primitives.workspace = true -thiserror.workspace = true -hex.workspace = true -prost.workspace = true -zaino-rpc.workspace = true -serde_json.workspace = true -zaino-status.workspace = true [dev-dependencies] -tonic.workspace = true -wire_serialized_transaction_test_data.workspace = true +# Drives `legacy_parser::transaction`'s own round-trip tests. +wire_serialized_transaction_test_data = { workspace = true } diff --git a/live-tests/zaino-testutils/src/finalised.rs b/live-tests/zaino-testutils/src/finalised.rs new file mode 100644 index 00000000..75a4cc53 --- /dev/null +++ b/live-tests/zaino-testutils/src/finalised.rs @@ -0,0 +1,52 @@ +//! Gating on zaino's finalised index. + +use std::time::Duration; + +use anyhow::Result; +use ztest::prelude::{family, Exporter, ZainoIndexer}; + +/// Per-scrape budget — `timeout` bounds the whole poll loop, not one round trip +const SCRAPE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Polls the finalised writer's committed tip until it reaches `target`. +/// +/// No served height answers this. Everything above the seam comes from the +/// in-memory chain head, and below it zaino serves straight from the validator +/// it proxies, so both read correct at heights the index has never written. +/// +/// `zaino_db_tip_height` and not [`ZainoIndexer::index_frontier`]: that reads +/// `zaino_sync_finalized_height`, set only inside a throttled progress-log +/// branch and so never created on a short chain, then falls back to a gauge +/// zaino does not emit. +/// +/// Requires the indexer image to carry the `prometheus` feature; without it +/// there is no exporter to scrape and this returns `Err` rather than hanging. +/// +/// Only meaningful where the chain is longer than the seam depth — the writer +/// targets `tip - MAX_NONFINALISED_DEPTH` (1001, or 100 under `fast-test-seam`) +/// and saturates to genesis below that. +pub async fn wait_for_finalised( + indexer: &ZainoIndexer, + target: u32, + timeout: Duration, +) -> Result { + let deadline = tokio::time::Instant::now() + timeout; + loop { + // Absent until the first batch commit — keep polling, do not abort. + let frontier = indexer + .read(SCRAPE_TIMEOUT) + .await + .map_err(anyhow::Error::msg)? + .height_gauge(family("zaino_db_tip_height")); + if let Some(frontier) = frontier.filter(|f| *f >= target) { + return Ok(frontier); + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "finalised index reached {frontier:?}, never {target}, within {timeout:?}; the \ + usual cause is an indexer image built without `fast-test-seam`, whose write \ + target is `tip - 1001` and saturates to genesis on a short chain" + ); + tokio::time::sleep(Duration::from_secs(2)).await; + } +} diff --git a/live-tests/zaino-testutils/src/hex.rs b/live-tests/zaino-testutils/src/hex.rs new file mode 100644 index 00000000..8d307e3e --- /dev/null +++ b/live-tests/zaino-testutils/src/hex.rs @@ -0,0 +1,69 @@ +//! Hex conversion for payloads that cross the JSON-RPC / gRPC boundary. +//! +//! zaino's gRPC surface hands back raw bytes (`RawTransaction.data`, compact +//! block hashes) while the validator's JSON-RPC surface hands back hex strings +//! for the same objects. Every test that uses the validator as an oracle for +//! something zaino served has to bridge that, so the conversion lives here +//! rather than being re-privately-defined per test file. + +use anyhow::{Context, Result}; + +/// Decode a lowercase-or-uppercase hex string into bytes. +/// +/// `label` names what was being decoded, so a malformed payload says which +/// RPC produced it rather than just "invalid digit". +pub fn decode(s: &str, label: &str) -> Result> { + hex::decode(s).with_context(|| format!("{label}: `{s}` is not a hex payload")) +} + +/// Encode bytes as a lowercase hex string, the form the validator's JSON-RPC +/// responses use. +pub fn encode(bytes: &[u8]) -> String { + hex::encode(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips() { + let bytes = vec![0x00, 0x0f, 0xa5, 0xff]; + assert_eq!(encode(&bytes), "000fa5ff"); + assert_eq!(decode("000fa5ff", "test").unwrap(), bytes); + } + + #[test] + fn decode_accepts_uppercase() { + assert_eq!(decode("A5FF", "test").unwrap(), vec![0xa5, 0xff]); + } + + #[test] + fn decode_rejects_odd_length() { + let err = format!("{:#}", decode("abc", "getrawtransaction").unwrap_err()); + assert!(err.contains("Odd number of digits"), "unexpected: {err}"); + assert!( + err.contains("getrawtransaction"), + "error must name the source: {err}" + ); + } + + #[test] + fn decode_rejects_non_hex() { + let err = format!("{:#}", decode("zz", "getrawtransaction").unwrap_err()); + assert!( + err.contains("getrawtransaction"), + "error must name the source: {err}" + ); + } + + /// Multi-byte input used to be sliced at a byte offset inside a char. + #[test] + fn decode_reports_non_ascii_rather_than_panicking() { + let err = format!("{:#}", decode("aéb", "getrawtransaction").unwrap_err()); + assert!( + err.contains("getrawtransaction"), + "error must name the source: {err}" + ); + } +} diff --git a/live-tests/zaino-testutils/src/json.rs b/live-tests/zaino-testutils/src/json.rs new file mode 100644 index 00000000..5e278622 --- /dev/null +++ b/live-tests/zaino-testutils/src/json.rs @@ -0,0 +1,281 @@ +//! JSON-RPC response comparison helpers shared by the parity tests. +//! +//! Each comparison comes in two forms: a `json_*` function returning +//! `Result<()>`, and an `assert_json_*` wrapper that panics on the `Err`. +//! +//! The `Result` form is the composable one, and exists because a caller that +//! runs many comparisons against one expensively-built topology cannot afford +//! for the first mismatch to unwind the rest — see `clientless/tests/ +//! testnet_parity.rs`, which reports every check's verdict together, having +//! paid for its topology once. The `assert_` form stays for single-comparison +//! call sites, +//! where a panic is the clearest thing that can happen. + +use anyhow::{ensure, Result}; +use serde_json::Value; + +/// Assert two JSON-RPC responses agree, after removing fields named by +/// `volatile` paths (dot-separated). Removal (not zeroing) handles the +/// common parity case where one side emits a field zaino mirrors as +/// `null` and the other side omits it entirely. +/// +/// Numbers compare by value, not representation: `1` and `1.0` are equal. +/// zaino's typed wire structs promote integer-valued fields (`difficulty`, +/// `relayfee`, …) to `f64`, so the indexer re-serializes them as `1.0` +/// while the validator emits `1`. +/// +/// A `volatile` path that matches nothing on either side is an error: it is +/// either a typo or dead, and both silently weaken the comparison. +pub fn json_equal_ignoring( + label: &str, + mut a: Value, + mut b: Value, + volatile: &[&str], +) -> Result<()> { + for path in volatile { + let hit_a = remove_path(&mut a, path); + let hit_b = remove_path(&mut b, path); + ensure!( + hit_a || hit_b, + "[{label}] volatile path {path:?} matched nothing on either side\n \ + left (validator): {a}\n right (indexer): {b}" + ); + } + ensure!( + json_eq_numeric(&a, &b), + "[{label}] validator and indexer JSON-RPC responses disagree \ + (after dropping {volatile:?})\n left (validator): {a}\n right (indexer): {b}" + ); + Ok(()) +} + +/// Panicking form of [`json_equal_ignoring`]. +pub fn assert_json_equal_ignoring(label: &str, a: Value, b: Value, volatile: &[&str]) { + if let Err(e) = json_equal_ignoring(label, a, b, volatile) { + panic!("{e:#}"); + } +} + +/// Structural JSON equality that compares numbers by value rather than +/// representation, so `1 == 1.0`. Recurses through objects and arrays; +/// every other variant falls back to `PartialEq`. +/// +/// Two integers compare as integers. `f64` only enters when a side is +/// genuinely a float, so distinct integers above 2^53 stay distinct. +fn json_eq_numeric(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_u64(), y.as_u64()) { + (Some(p), Some(q)) => p == q, + _ => match (x.as_i64(), y.as_i64()) { + (Some(p), Some(q)) => p == q, + _ => matches!((x.as_f64(), y.as_f64()), (Some(p), Some(q)) if p == q), + }, + }, + (Value::Object(x), Value::Object(y)) => { + x.len() == y.len() + && x.iter() + .all(|(k, xv)| y.get(k).is_some_and(|yv| json_eq_numeric(xv, yv))) + } + (Value::Array(x), Value::Array(y)) => { + x.len() == y.len() && x.iter().zip(y).all(|(xv, yv)| json_eq_numeric(xv, yv)) + } + _ => a == b, + } +} + +/// Remove the value at a dot-separated `path` from `v`, reporting whether +/// anything was removed. A missing key, or a scalar encountered before the +/// leaf, is a no-op. +/// +/// A path only ever removes at the level it names. Two implicit wildcards +/// keep paths free of index/key syntax: +/// - **Arrays**: the remaining path is applied to every object element, +/// so `valuePools.monitored` strips `monitored` from each `valuePools` +/// entry. Transparency does not stack — elements that are themselves +/// arrays are left alone, so the path cannot reach deeper than the +/// level it names. +/// - **`*` segment on an object**: the remaining path is applied to +/// every value, so `upgrades.*.info` strips `info` from every entry of +/// the (arbitrary-keyed) `upgrades` map. A trailing `*` clears the +/// object. +fn remove_path(v: &mut Value, path: &str) -> bool { + // Every branch visits all candidates: `any` would stop at the first hit + // and leave the rest of the volatile field in place. + match v { + Value::Array(items) => { + let mut hit = false; + for item in items.iter_mut().filter(|item| item.is_object()) { + hit |= remove_path(item, path); + } + hit + } + Value::Object(m) => match path.split_once('.') { + None if path == "*" => { + let hit = !m.is_empty(); + m.clear(); + hit + } + None => m.remove(path).is_some(), + Some(("*", rest)) => { + let mut hit = false; + for val in m.values_mut() { + hit |= remove_path(val, rest); + } + hit + } + Some((head, rest)) => m.get_mut(head).is_some_and(|next| remove_path(next, rest)), + }, + _ => false, + } +} + +/// Canonicalize an array-valued JSON-RPC response by sorting its elements, +/// so set-valued results compare regardless of the order each source +/// happened to emit them in. +/// +/// Necessary because zaino's state backend derives results from its own +/// index while the validator walks its own storage: the *contents* are the +/// contract, the order is not. (`getrawmempool` already needed this and was +/// sorted inline at the call site; the address-index queries need the same +/// treatment over real history, where results are long enough that an +/// order difference is likely rather than theoretical.) +/// +/// Non-arrays pass through unchanged, so this is safe to apply to a +/// response that may legitimately be an error object or a scalar. Sorting +/// is by serialized form, which is total and stable across runs. +pub fn sort_json_array(v: Value) -> Value { + match v { + Value::Array(mut items) => { + items.sort_by_key(|item| item.to_string()); + Value::Array(items) + } + other => other, + } +} + +/// Light parity check between two JSON-RPC objects. After dropping +/// `ignore` paths from both sides, asserts: +/// - both are JSON objects +/// - their top-level key sets are equal (and thus equal in count) +/// - for each name in `check_values`, the values are byte-equal +/// +/// For fields where validator and indexer disagree on numeric +/// representation (e.g. zaino's typed wire structs promote `difficulty` +/// to f64 while the validator emits an int), don't include them in +/// `check_values` — assert those at the call site with `.as_f64()` on +/// both sides. +pub fn json_shape_matches( + label: &str, + mut a: Value, + mut b: Value, + ignore: &[&str], + check_values: &[&str], +) -> Result<()> { + for path in ignore { + remove_path(&mut a, path); + remove_path(&mut b, path); + } + let (Value::Object(a_obj), Value::Object(b_obj)) = (&a, &b) else { + anyhow::bail!("[{label}] expected JSON objects, got validator={a}, indexer={b}"); + }; + let a_keys: std::collections::BTreeSet<&String> = a_obj.keys().collect(); + let b_keys: std::collections::BTreeSet<&String> = b_obj.keys().collect(); + ensure!( + a_keys == b_keys, + "[{label}] key set differs: validator={a_keys:?}, indexer={b_keys:?}" + ); + for name in check_values { + // Absent on both sides would otherwise compare equal, so a typo'd or + // already-ignored name would pass vacuously. + let (Some(av), Some(bv)) = (a_obj.get(*name), b_obj.get(*name)) else { + let missing = match (a_obj.contains_key(*name), b_obj.contains_key(*name)) { + (false, false) => "neither validator nor indexer", + (false, true) => "validator", + _ => "indexer", + }; + anyhow::bail!("[{label}] checked field {name:?} is absent from {missing}"); + }; + ensure!( + av == bv, + "[{label}] field {name:?} differs: validator={av}, indexer={bv}" + ); + } + Ok(()) +} + +/// Panicking form of [`json_shape_matches`]. +pub fn assert_json_shape_matches( + label: &str, + a: Value, + b: Value, + ignore: &[&str], + check_values: &[&str], +) { + if let Err(e) = json_shape_matches(label, a, b, ignore, check_values) { + panic!("{e:#}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Above 2^53 an f64 comparison collapses adjacent integers into one. + #[test] + fn large_distinct_integers_are_unequal() { + let a = json!({ "n": 9007199254740993u64 }); + let b = json!({ "n": 9007199254740992u64 }); + assert!(json_equal_ignoring("n", a, b, &[]).is_err()); + } + + #[test] + fn int_and_float_of_the_same_value_are_equal() { + let a = json!({ "difficulty": 1 }); + let b = json!({ "difficulty": 1.0 }); + json_equal_ignoring("difficulty", a, b, &[]).unwrap(); + } + + #[test] + fn a_path_removes_only_at_the_level_it_names() { + let mut v = json!({ "errors": "top", "nested": { "errors": "deep" } }); + assert!(remove_path(&mut v, "errors")); + assert_eq!(v, json!({ "nested": { "errors": "deep" } })); + } + + #[test] + fn array_transparency_does_not_stack() { + let mut v = json!({ "pools": [{ "id": 1 }, [{ "id": 2 }]] }); + assert!(remove_path(&mut v, "pools.id")); + assert_eq!(v, json!({ "pools": [{}, [{ "id": 2 }]] })); + } + + #[test] + fn a_volatile_path_matching_nothing_is_an_error() { + let err = json_equal_ignoring("getinfo", json!({ "a": 1 }), json!({ "a": 1 }), &["typo"]) + .unwrap_err() + .to_string(); + assert!(err.contains("matched nothing"), "unexpected: {err}"); + } + + /// One-sided presence is a real parity difference, so removal there still + /// counts as the path firing. + #[test] + fn a_volatile_path_matching_one_side_is_accepted() { + json_equal_ignoring( + "getinfo", + json!({ "a": 1, "b": 2 }), + json!({ "a": 1 }), + &["b"], + ) + .unwrap(); + } + + #[test] + fn a_checked_field_absent_from_both_sides_fails() { + let err = json_shape_matches("getinfo", json!({}), json!({}), &[], &["blocks"]) + .unwrap_err() + .to_string(); + assert!(err.contains("absent from neither"), "unexpected: {err}"); + } +} diff --git a/live-tests/zaino-testutils/src/legacy_parser.rs b/live-tests/zaino-testutils/src/legacy_parser.rs index 0d93f8f6..484c6f22 100644 --- a/live-tests/zaino-testutils/src/legacy_parser.rs +++ b/live-tests/zaino-testutils/src/legacy_parser.rs @@ -5,18 +5,27 @@ //! //! # Why keep a second parser at all //! -//! Production parses blocks through `zebra-chain`. The live-test vectors -//! (`e2e/tests/test_vectors.rs`, `clientless/tests/test_vectors.rs`) exist to -//! catch the class of bug where a parser is subtly wrong — a field read at the -//! wrong offset, a byte order flipped — and they catch it by parsing the same -//! bytes a second, independent way and comparing. Rewriting these vectors -//! against `zebra-chain` would collapse the oracle onto the parser under test -//! and remove the reason the vectors are there. +//! Production parses blocks through `zebra-chain`. The transaction vectors in +//! `clientless/tests/test_vectors.rs` exist to catch the class of bug where a +//! parser is subtly wrong — a field read at the wrong offset, a byte order +//! flipped — and they catch it by parsing the same bytes a second, independent +//! way and comparing. Rewriting those vectors against `zebra-chain` would +//! collapse the oracle onto the parser under test and remove the reason the +//! vectors are there. //! //! So it stays, and it stays *here*: the cost is a few hundred lines living in //! the test tree, and in exchange no production crate carries a parser it does //! not use. //! +//! # Scope +//! +//! Only the parse side survives the ztest migration. The `indexed_block` +//! bridge — which rendered a parsed block as `zaino-state`'s indexed types — +//! served the in-process vector generator that the migration replaced, and it +//! was the sole reason this crate linked `zaino-state`. Dropping it keeps the +//! live-test workspace off the production dependency graph. Restore it from +//! history if vector generation is ever re-homed to a `zaino-state` unit test. +//! //! # Not a maintained implementation //! //! Nothing outside the live tests may depend on this. It is not kept in step @@ -28,7 +37,3 @@ pub mod block; pub mod error; pub mod transaction; pub mod utils; - -mod indexed_block; - -pub use indexed_block::{compact_tx_data_from_full_transaction, indexed_block_from_full_block}; diff --git a/live-tests/zaino-testutils/src/legacy_parser/block.rs b/live-tests/zaino-testutils/src/legacy_parser/block.rs index 460cac26..7835513a 100644 --- a/live-tests/zaino-testutils/src/legacy_parser/block.rs +++ b/live-tests/zaino-testutils/src/legacy_parser/block.rs @@ -198,7 +198,6 @@ impl FullBlock { .collect::, _>>()?; Ok(CompactBlock { - proto_version: 1, height: self.height as u64, hash: self.hdr.cached_hash.clone(), prev_hash: self.hdr.hash_prev_block(), diff --git a/live-tests/zaino-testutils/src/legacy_parser/indexed_block.rs b/live-tests/zaino-testutils/src/legacy_parser/indexed_block.rs deleted file mode 100644 index ce60e8e2..00000000 --- a/live-tests/zaino-testutils/src/legacy_parser/indexed_block.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! Building the indexed on-disk shapes from an independently-parsed block. -//! -//! These were `TryFrom` impls on `IndexedBlock` and `CompactTxData` inside -//! `zaino-state`, reachable only from the `e2e` test-vector generator. They are -//! free functions here for two reasons: the orphan rule forbids implementing a -//! foreign trait for a foreign type from this crate, and — more to the point — -//! logic that exists to *generate test vectors* belongs with the vectors, not in -//! a production crate. -//! -//! Nothing in production builds an `IndexedBlock` this way. The sync path builds -//! one from `BlockWithMetadata`, and that impl is untouched. - -use zaino_state::chain_index::types::{ - parse_standard_script, BlockContext, BlockData, ChainWork, CommitmentTreeData, - CommitmentTreeRoots, CommitmentTreeSizes, CompactDifficulty, CompactOrchardAction, - CompactSaplingOutput, CompactSaplingSpend, CompactTxData, EquihashSolution, IndexedBlock, - OrchardCompactTx, SaplingCompactTx, ScriptType, TransparentCompactTx, TxInCompact, - TxOutCompact, -}; -use zaino_state::{BlockHash, Height}; - -use super::{block::FullBlock, transaction::FullTransaction}; - -/// Converts one Orchard-shaped action tuple into its compact on-disk form. -/// -/// Shared by the Orchard and Ironwood pools, which have the same action shape; -/// `pool` names the pool only so a length failure says which one. -fn compact_orchard_action_from_parts( - pool: &str, - (nullifier, cmx, ephemeral_key, ciphertext): (Vec, Vec, Vec, Vec), -) -> Result { - let nf: [u8; 32] = nullifier - .try_into() - .map_err(|_| format!("{pool} nullifier must be 32 bytes"))?; - let cmx: [u8; 32] = cmx - .try_into() - .map_err(|_| format!("{pool} cmx must be 32 bytes"))?; - let epk: [u8; 32] = ephemeral_key - .try_into() - .map_err(|_| format!("{pool} ephemeral_key must be 32 bytes"))?; - let ct: [u8; 52] = ciphertext - .get(..52) - .ok_or_else(|| format!("{pool} ciphertext must be at least 52 bytes"))? - .try_into() - .map_err(|_| format!("{pool} ciphertext must be 52 bytes"))?; - Ok(CompactOrchardAction::new(nf, cmx, epk, ct)) -} - -/// Builds the indexed compact transaction from an independently-parsed one. -/// -/// `index` is the transaction's position within its block. -pub fn compact_tx_data_from_full_transaction( - index: u64, - tx: FullTransaction, -) -> Result { - let txid: [u8; 32] = tx - .tx_id() - .try_into() - .map_err(|_| "txid must be 32 bytes".to_string())?; - - let (sapling_balance, orchard_balance, ironwood_balance) = tx.value_balances(); - - let vin: Vec = tx - .transparent_inputs() - .into_iter() - .map(|(prev_txid, prev_index, _)| { - let prev_txid_arr: [u8; 32] = prev_txid - .try_into() - .map_err(|_| "prev_txid must be 32 bytes".to_string())?; - Ok::<_, String>(TxInCompact::new(prev_txid_arr, prev_index)) - }) - .collect::>()?; - - // A script this parser does not recognise is stored as its first 20 bytes - // under `NonStandard`, matching how the indexer stores one. - let vout: Vec = tx - .transparent_outputs() - .into_iter() - .filter_map(|(value, script)| { - if let Some((hash20, stype)) = parse_standard_script(&script) { - TxOutCompact::new(value, hash20, stype as u8) - } else { - let mut fallback = [0u8; 20]; - let copy_len = script.len().min(20); - fallback[..copy_len].copy_from_slice(&script[..copy_len]); - TxOutCompact::new(value, fallback, ScriptType::NonStandard as u8) - } - }) - .collect(); - - let transparent = TransparentCompactTx::new(vin, vout); - - let spends: Vec = tx - .shielded_spends() - .into_iter() - .map(|nf| { - let arr: [u8; 32] = nf - .try_into() - .map_err(|_| "sapling nullifier must be 32 bytes".to_string())?; - Ok::<_, String>(CompactSaplingSpend::new(arr)) - }) - .collect::>()?; - - let outputs: Vec = tx - .shielded_outputs() - .into_iter() - .map(|(cmu, epk, ct)| { - let cmu: [u8; 32] = cmu - .try_into() - .map_err(|_| "cmu must be 32 bytes".to_string())?; - let epk: [u8; 32] = epk - .try_into() - .map_err(|_| "ephemeral_key must be 32 bytes".to_string())?; - let ct: [u8; 52] = ct - .get(..52) - .ok_or("ciphertext must be at least 52 bytes")? - .try_into() - .map_err(|_| "ciphertext must be 52 bytes".to_string())?; - Ok::<_, String>(CompactSaplingOutput::new(cmu, epk, ct)) - }) - .collect::>()?; - - let sapling = SaplingCompactTx::new(sapling_balance, spends, outputs); - - let orchard_actions: Vec = tx - .orchard_actions() - .into_iter() - .map(|action| compact_orchard_action_from_parts("orchard", action)) - .collect::>()?; - let orchard = OrchardCompactTx::new(orchard_balance, orchard_actions); - - let ironwood_actions: Vec = tx - .ironwood_actions() - .into_iter() - .map(|action| compact_orchard_action_from_parts("ironwood", action)) - .collect::>()?; - let ironwood = OrchardCompactTx::new(ironwood_balance, ironwood_actions); - - Ok(CompactTxData::new( - index, - txid.into(), - transparent, - sapling, - orchard, - ironwood, - )) -} - -/// Builds an indexed block from an independently-parsed one. -/// -/// The commitment tree roots and the parent's tree sizes and chainwork are not -/// in the block, so the caller supplies them — the same facts the sync path -/// carries in its `BlockWithMetadata`. -#[allow(clippy::too_many_arguments)] -pub fn indexed_block_from_full_block( - full_block: FullBlock, - parent_chainwork: Option, - final_sapling_root: [u8; 32], - final_orchard_root: [u8; 32], - final_ironwood_root: Option<[u8; 32]>, - parent_sapling_size: u32, - parent_orchard_size: u32, - parent_ironwood_size: u32, -) -> Result { - let header = full_block.header(); - let height = Height::try_from(full_block.height() as u32) - .map_err(|e| format!("Invalid block height: {e}"))?; - - let hash: [u8; 32] = header - .cached_hash() - .try_into() - .map_err(|_| "Block hash must be 32 bytes")?; - let parent_hash: [u8; 32] = header - .hash_prev_block() - .try_into() - .map_err(|_| "Parent block hash must be 32 bytes")?; - - let merkle_root: [u8; 32] = header - .hash_merkle_root() - .try_into() - .map_err(|v: Vec| format!("merkle root must be 32 bytes, got {}", v.len()))?; - - let block_commitments: [u8; 32] = header - .final_sapling_root() - .try_into() - .map_err(|v: Vec| format!("block commitment must be 32 bytes, got {}", v.len()))?; - - let n_bits_bytes = header.n_bits_bytes(); - if n_bits_bytes.len() != 4 { - return Err("nBits must be 4 bytes".to_string()); - } - let bits_raw = u32::from_le_bytes( - n_bits_bytes - .try_into() - .map_err(|_| "nBits must be 4 bytes".to_string())?, - ); - let bits = - CompactDifficulty::try_from_bits(bits_raw).map_err(|e| format!("invalid nBits: {e}"))?; - - let nonse: [u8; 32] = header - .nonce() - .try_into() - .map_err(|v: Vec| format!("nonse must be 32 bytes, got {}", v.len()))?; - - let solution = EquihashSolution::try_from(header.solution()).map_err(|_| { - format!( - "solution must be 32 or 1344 bytes, got {}", - header.solution().len() - ) - })?; - - // Tree sizes are cumulative, so each block's size is its parent's plus the - // notes this block adds. Counting them is why the transactions are - // converted before the trees are built. - let mut sapling_note_count = 0; - let mut orchard_note_count = 0; - let mut ironwood_note_count = 0; - - let full_transactions = full_block.transactions(); - let mut tx = Vec::with_capacity(full_transactions.len()); - - for (i, ftx) in full_transactions.into_iter().enumerate() { - let txdata = compact_tx_data_from_full_transaction(i as u64, ftx) - .map_err(|e| format!("TxData conversion failed at index {i}: {e}"))?; - - sapling_note_count += txdata.sapling().outputs().len(); - orchard_note_count += txdata.orchard().actions().len(); - ironwood_note_count += txdata.ironwood().actions().len(); - - tx.push(txdata); - } - - let commitment_tree_data = CommitmentTreeData::new( - CommitmentTreeRoots::new(final_sapling_root, final_orchard_root, final_ironwood_root), - CommitmentTreeSizes::new( - parent_sapling_size + sapling_note_count as u32, - parent_orchard_size + orchard_note_count as u32, - parent_ironwood_size + ironwood_note_count as u32, - ), - ); - - let block_data = BlockData { - version: header.version() as u32, - time: header.time() as i64, - merkle_root, - block_commitments, - bits, - nonce: nonse, - solution, - }; - - // Chainwork is cumulative too: this block's work added to its parent's. - let block_work = block_data.bits.to_work(); - let chainwork = match parent_chainwork { - Some(parent) => parent - .add(&block_work) - .map_err(|e| format!("chainwork overflow: {e}"))?, - None => block_work, - }; - - let context = BlockContext::new( - BlockHash::from(hash), - BlockHash::from(parent_hash), - chainwork, - height, - ); - - Ok(IndexedBlock::new( - context, - block_data, - tx, - commitment_tree_data, - )) -} diff --git a/live-tests/zaino-testutils/src/lib.rs b/live-tests/zaino-testutils/src/lib.rs index c7854db2..dabfd0fb 100644 --- a/live-tests/zaino-testutils/src/lib.rs +++ b/live-tests/zaino-testutils/src/lib.rs @@ -1,1622 +1,28 @@ -//! Zaino Testing Utilities. +//! Shared helpers for Zaino live tests running on the ztest Kubernetes harness. +//! +//! The harness proper — validator / indexer / wallet lifecycle and the typed +//! RPC handles test code drives — is [`ztest`]; this crate adds the small +//! conveniences the live tests share: JSON-RPC parity assertions +//! (`rpc`/`json`), hex conversion across the JSON-RPC / gRPC boundary +//! (`hex`), gating on the finalised index (`finalised`), and the independent +//! second transaction parser (`legacy_parser`). -#![warn(missing_docs)] #![forbid(unsafe_code)] +#![warn(missing_docs)] -pub mod legacy_parser; -pub mod validator_oracle; - -pub use validator_oracle::ValidatorOracle; - -use futures::StreamExt as _; -use once_cell::sync::Lazy; -use std::{ - future::Future, - marker::PhantomData, - net::{IpAddr, Ipv4Addr, SocketAddr}, - path::PathBuf, -}; -#[cfg(test)] -use tonic::transport::Channel; -use tracing::{debug, info, instrument}; -use zaino_common::{ - network::ActivationHeights, validator::ValidatorConfig, CacheConfig, DatabaseConfig, Network, - ServiceConfig, StorageConfig, -}; -use zaino_proto::proto::compact_formats::CompactBlock; -use zaino_proto::proto::service::{BlockId, BlockRange}; -use zaino_serve::server::config::{GrpcServerConfig, JsonRpcServerConfig}; -use zaino_state::{ - BlockchainSource, ChainIndex, LightWalletIndexer, NodeBackedChainIndexSubscriber, - NodeBackedIndexerService, NodeBackedIndexerServiceConfig, NodeBackedIndexerServiceSubscriber, - ZcashService, -}; -use zaino_status::{Liveness, Readiness, Status}; -use zainodlib::{config::BackendType, error::IndexerError, indexer::Indexer}; -pub use zcash_local_net as services; -use zcash_local_net::error::LaunchError; -#[cfg(feature = "zcashd_support")] -use zcash_local_net::validator::zcashd::{Zcashd, ZcashdConfig}; -use zcash_local_net::validator::zebrad::{Zebrad, ZebradConfig}; -pub use zcash_local_net::validator::Validator; -use zcash_local_net::validator::ValidatorConfig as _; -pub use zcash_local_net::MinerPool; -use zcash_local_net::{logs::LogsToStdoutAndStderr, process::Process}; -use zebra_chain::parameters::NetworkKind; - -#[cfg(test)] -use zaino_proto::proto::service::compact_tx_streamer_client::CompactTxStreamerClient; - -/// Generates one `#[tokio::test(flavor = "multi_thread")]` wrapper per -/// `name => helper` pair, each calling `helper::<$validator>(&$kind).await`. -/// -/// Collapses the per-validator boilerplate in the `fetch_service` / -/// `state_service` test modules: invoke once per validator inside the relevant -/// `mod`, supplying that validator's test list. A macro (not a fn) because each -/// wrapper must be a discoverable `#[tokio::test]` item. -#[macro_export] -macro_rules! validator_tests { - ($validator:ty, $kind:expr, $( $name:ident => $helper:ident ),* $(,)?) => { - $( - #[tokio::test(flavor = "multi_thread")] - pub(crate) async fn $name() { - $helper::<$validator>(&$kind).await; - } - )* - }; -} - -/// The canonical regtest activation heights the harness *launches* validators -/// with when a test names no others (everything through NU6.3 active from -/// height 2). This is validator-launch vocabulary — the configuring side of -/// the truth source — and is never zainod's own schedule: zainod's config -/// carries only a network kind, and both backends adopt the runtime schedule -/// from the running validator's `getblockchaininfo.upgrades` (zaino#1076). -/// It matches zcash_local_net's `supported_regtest_activation_heights`, which -/// the devtool wallet client currently requires (zaino#1368). -pub const ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS: ActivationHeights = ActivationHeights { - before_overwinter: Some(1), - overwinter: Some(1), - sapling: Some(1), - blossom: Some(1), - heartwood: Some(1), - canopy: Some(1), - nu5: Some(2), - nu6: Some(2), - nu6_1: Some(2), - nu6_2: Some(2), - nu6_3: Some(2), - nu7: None, -}; - -/// Orchard-era-only fixture: every upgrade through NU6.2 active from height 2 and -/// NU6.3 never activating, so coinbases stay Orchard for the whole chain. For -/// client-free launches only — the zcash-devtool wallet hardcodes the canonical -/// regtest set (NU6.3 at 2), so wallet-built transactions on this fixture would fail -/// consensus branch-id validation. -pub const ORCHARD_ONLY_ACTIVATION_HEIGHTS: ActivationHeights = ActivationHeights { - before_overwinter: Some(1), - overwinter: Some(1), - sapling: Some(1), - blossom: Some(1), - heartwood: Some(1), - canopy: Some(1), - nu5: Some(2), - nu6: Some(2), - nu6_1: Some(2), - nu6_2: Some(2), - nu6_3: None, - nu7: None, -}; +pub use ztest; +pub use ztest::prelude::*; -/// Zebrad regtest heights with every upgrade through NU6.3 active from height 2, so -/// generated blocks carry V6 coinbases from the first post-genesis era. -pub const IRONWOOD_ONLY_ACTIVATION_HEIGHTS: ActivationHeights = ActivationHeights { - before_overwinter: Some(1), - overwinter: Some(1), - sapling: Some(1), - blossom: Some(1), - heartwood: Some(1), - canopy: Some(1), - nu5: Some(2), - nu6: Some(2), - nu6_1: Some(2), - nu6_2: Some(2), - nu6_3: Some(2), - nu7: None, -}; +pub mod legacy_parser; -/// Keep in sync with [`ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS`]. -pub const NU6_3_TRANSITION_BOUNDARY: u32 = 6; +pub mod finalised; +pub mod hex; +pub mod json; +pub mod rpc; -/// NU5 era from height 2, NU6.3 from [`NU6_3_TRANSITION_BOUNDARY`]: the same -/// orchard-receiver miner first produces Orchard coinbases, then — at the activation -/// boundary — Ironwood ones. -pub const ORCHARD_THEN_IRONWOOD_ACTIVATION_HEIGHTS: ActivationHeights = ActivationHeights { - before_overwinter: Some(1), - overwinter: Some(1), - sapling: Some(1), - blossom: Some(1), - heartwood: Some(1), - canopy: Some(1), - nu5: Some(2), - nu6: Some(2), - nu6_1: Some(2), - nu6_2: Some(2), - nu6_3: Some(NU6_3_TRANSITION_BOUNDARY), - nu7: None, +pub use finalised::wait_for_finalised; +pub use json::{ + assert_json_equal_ignoring, assert_json_shape_matches, json_equal_ignoring, json_shape_matches, + sort_json_array, }; - -/// All four value pools as the `i32`s a `get_block_range` request carries — -/// the "request everything" pool filter. -pub fn all_pools_i32() -> Vec { - use zaino_proto::proto::utils::{pool_types_into_i32_vec, PoolTypeFilter}; - pool_types_into_i32_vec(&PoolTypeFilter::includes_all().to_pool_types_vector()) -} - -/// The shielded pools as request `i32`s — what `get_block_range` defaults to -/// when a request names no pools (`PoolTypeFilter::default()`: every shielded -/// pool including Ironwood, transparent excluded). -pub fn shielded_pools_i32() -> Vec { - use zaino_proto::proto::utils::{pool_types_into_i32_vec, PoolTypeFilter}; - pool_types_into_i32_vec(&PoolTypeFilter::default().to_pool_types_vector()) -} - -/// Collect a `get_block_range` query over heights `[start, end]` for the given -/// proto `pool_types` into a vector of compact blocks. Generic over the -/// lightwallet subscriber, so it serves both `FetchServiceSubscriber` and -/// `StateServiceSubscriber`. Shared by the fetch_service and state_service -/// tests in both workspaces. -#[allow(deprecated)] -pub async fn collect_block_range( - subscriber: &S, - start: u64, - end: u64, - pool_types: Vec, -) -> Vec { - subscriber - .get_block_range(BlockRange { - start: Some(BlockId { - height: start, - hash: vec![], - }), - end: Some(BlockId { - height: end, - hash: vec![], - }), - pool_types, - }) - .await - .expect("get_block_range") - .map(|block| block.expect("compact block in range")) - .collect() - .await -} - -/// Drain a `get_block_range` query over heights `[start, end]` for `pool_types`, -/// collecting the compact blocks and reporting whether the stream terminated -/// with an error (`true`) rather than a clean end-of-stream (`false`). The -/// error-tolerant counterpart to [`collect_block_range`], for out-of-range -/// tests that expect the stream to error partway through. -#[allow(deprecated)] -pub async fn drain_block_range( - subscriber: &S, - start: u64, - end: u64, - pool_types: Vec, -) -> (Vec, bool) { - let mut stream = subscriber - .get_block_range(BlockRange { - start: Some(BlockId { - height: start, - hash: vec![], - }), - end: Some(BlockId { - height: end, - hash: vec![], - }), - pool_types, - }) - .await - .expect("get_block_range"); - let mut blocks = Vec::new(); - let mut errored = false; - while let Some(item) = stream.next().await { - match item { - Ok(block) => blocks.push(block), - Err(_) => { - errored = true; - break; - } - } - } - (blocks, errored) -} - -/// Helper to get the test binary path from the TEST_BINARIES_DIR env var. -fn binary_path(binary_name: &str) -> Option { - std::env::var("TEST_BINARIES_DIR") - .ok() - .map(|dir| PathBuf::from(dir).join(binary_name)) -} - -/// Create local URI from port. -pub fn make_uri(indexer_port: u16) -> http::Uri { - format!("http://127.0.0.1:{indexer_port}") - .try_into() - .unwrap() -} - -/// Polls until the given component reports ready. -/// -/// Returns `true` if the component became ready within the timeout, -/// `false` if the timeout was reached. -#[instrument(name = "poll_until_ready", skip(component), fields(timeout_ms = timeout.as_millis() as u64))] -pub async fn poll_until_ready( - component: &impl Readiness, - poll_interval: std::time::Duration, - timeout: std::time::Duration, -) -> bool { - debug!("[POLL] Waiting for component to be ready"); - let result = tokio::time::timeout(timeout, async { - let mut interval = tokio::time::interval(poll_interval); - loop { - interval.tick().await; - if component.is_ready() { - return; - } - } - }) - .await - .is_ok(); - if result { - debug!("[POLL] Component is ready"); - } else { - debug!("[POLL] Timeout waiting for component"); - } - result -} - -/// Source of "current tip height" for the unified block-and-wait helper. -/// -/// Implementors are anything a test wants to wait on: a Zaino service -/// subscriber or a `NodeBackedChainIndexSubscriber`. -/// -/// [`Status`] (and through it [`Liveness`] / [`Readiness`] via the blanket -/// impls in `zaino_status`) is a supertrait so a single -/// `T: PollableTip` bound is everything the unified helper needs — it can -/// poll for height, fail fast on a dead backend, and wait for readiness -/// once the target height is reached. -/// -/// Impls are listed explicitly rather than blanket-impl'd over -/// `LightWalletIndexer` so the compiler can rule out coherence conflicts -/// with the `NodeBackedChainIndexSubscriber` impl (Rust's orphan rules -/// can't otherwise prove a foreign type won't grow an upstream -/// `LightWalletIndexer` impl). Add a new impl per pollable type. -pub trait PollableTip: Status + Sync { - /// Current observable tip height, in absolute block-height units. - fn tip_height(&self) -> impl std::future::Future; -} - -impl PollableTip for NodeBackedIndexerServiceSubscriber { - async fn tip_height(&self) -> u64 { - self.get_latest_block() - .await - .expect("PollableTip: NodeBackedIndexerServiceSubscriber::get_latest_block failed") - .height - } -} - -impl PollableTip for NodeBackedChainIndexSubscriber { - async fn tip_height(&self) -> u64 { - let snapshot = self - .snapshot_nonfinalized_state() - .await - .expect("PollableTip: chain-index snapshot_nonfinalized_state failed"); - u64::from(u32::from( - self.best_chaintip(&snapshot) - .await - .expect("PollableTip: chain-index best_chaintip failed") - .height, - )) - } -} - -/// Test-side selector for the validator connection the single -/// [`NodeBackedIndexerService`] uses, threaded as the `Conn` type parameter of -/// [`TestManager`]. -/// -/// Replaces the former `FetchService` / `StateService` `Service` type parameters: -/// there is now one service, parameterized by connection. Implementors are the -/// zero-sized markers [`Rpc`] and [`Direct`]. -pub trait ValidatorConnectionMarker: Send + Sync + 'static { - /// The on-disk backend selector this connection maps to. - const BACKEND: BackendType; -} - -/// JSON-RPC validator connection (formerly `FetchService`). -#[derive(Debug, Clone, Copy)] -pub struct Rpc; - -impl ValidatorConnectionMarker for Rpc { - const BACKEND: BackendType = BackendType::Rpc; -} - -/// Direct Zebra `ReadStateService` validator connection (formerly `StateService`). -#[derive(Debug, Clone, Copy)] -pub struct Direct; - -impl ValidatorConnectionMarker for Direct { - const BACKEND: BackendType = BackendType::Direct; -} - -// temporary until activation heights are unified to zebra-chain type. -// from/into impls not added in zaino-common to avoid unecessary addition of zcash-protocol dep to non-test code -/// Convert zaino activation heights into zcash protocol type. -pub fn local_network_from_activation_heights( - activation_heights: ActivationHeights, -) -> zcash_protocol::local_consensus::LocalNetwork { - zcash_protocol::local_consensus::LocalNetwork { - overwinter: activation_heights - .overwinter - .map(zcash_protocol::consensus::BlockHeight::from), - sapling: activation_heights - .sapling - .map(zcash_protocol::consensus::BlockHeight::from), - blossom: activation_heights - .blossom - .map(zcash_protocol::consensus::BlockHeight::from), - heartwood: activation_heights - .heartwood - .map(zcash_protocol::consensus::BlockHeight::from), - canopy: activation_heights - .canopy - .map(zcash_protocol::consensus::BlockHeight::from), - nu5: activation_heights - .nu5 - .map(zcash_protocol::consensus::BlockHeight::from), - nu6: activation_heights - .nu6 - .map(zcash_protocol::consensus::BlockHeight::from), - nu6_1: activation_heights - .nu6_1 - .map(zcash_protocol::consensus::BlockHeight::from), - nu6_2: activation_heights - .nu6_2 - .map(zcash_protocol::consensus::BlockHeight::from), - nu6_3: activation_heights - .nu6_3 - .map(zcash_protocol::consensus::BlockHeight::from), - } -} - -// Conversions at the zcash_local_net boundary. Named functions rather than -// From impls: the orphan rule forbids the impls here, and zaino-common must -// not depend on the harness vocabulary (it reaches us only through -// zcash_local_net's re-exports). -/// Convert zaino activation heights into the `zcash_local_net` activation heights type. -pub fn to_local_net_activation_heights( - activation_heights: &ActivationHeights, -) -> zcash_local_net::protocol::ActivationHeights { - zcash_local_net::protocol::ActivationHeights::builder() - .set_overwinter(activation_heights.overwinter) - .set_sapling(activation_heights.sapling) - .set_blossom(activation_heights.blossom) - .set_heartwood(activation_heights.heartwood) - .set_canopy(activation_heights.canopy) - .set_nu5(activation_heights.nu5) - .set_nu6(activation_heights.nu6) - .set_nu6_1(activation_heights.nu6_1) - .set_nu6_2(activation_heights.nu6_2) - .set_nu6_3(activation_heights.nu6_3) - .set_nu7(activation_heights.nu7) - .build() -} - -/// Convert the `zcash_local_net` activation heights type into zaino activation heights. -pub fn from_local_net_activation_heights( - activation_heights: &zcash_local_net::protocol::ActivationHeights, -) -> ActivationHeights { - ActivationHeights { - before_overwinter: activation_heights.overwinter(), - overwinter: activation_heights.overwinter(), - sapling: activation_heights.sapling(), - blossom: activation_heights.blossom(), - heartwood: activation_heights.heartwood(), - canopy: activation_heights.canopy(), - nu5: activation_heights.nu5(), - nu6: activation_heights.nu6(), - nu6_1: activation_heights.nu6_1(), - nu6_2: activation_heights.nu6_2(), - nu6_3: activation_heights.nu6_3(), - nu7: activation_heights.nu7(), - } -} - -/// Path for zcashd binary. -#[cfg(feature = "zcashd_support")] -pub static ZCASHD_BIN: Lazy> = Lazy::new(|| binary_path("zcashd")); - -/// Path for zcash-cli binary. -#[cfg(feature = "zcashd_support")] -pub static ZCASH_CLI_BIN: Lazy> = Lazy::new(|| binary_path("zcash-cli")); - -/// Path for zebrad binary. -pub static ZEBRAD_BIN: Lazy> = Lazy::new(|| binary_path("zebrad")); - -/// Path for lightwalletd binary. -pub static LIGHTWALLETD_BIN: Lazy> = Lazy::new(|| binary_path("lightwalletd")); - -/// Path for zainod binary. -pub static ZAINOD_BIN: Lazy> = Lazy::new(|| binary_path("zainod")); - -/// Path for zcashd chain cache. -#[cfg(feature = "zcashd_support")] -pub static ZCASHD_CHAIN_CACHE_DIR: Lazy> = Lazy::new(|| { - let mut workspace_root_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); - workspace_root_path.pop(); - Some(workspace_root_path.join("live-tests/chain_cache/client_rpc_tests")) -}); - -/// Path for zebrad chain cache. -pub static ZEBRAD_CHAIN_CACHE_DIR: Lazy> = Lazy::new(|| { - let mut workspace_root_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); - workspace_root_path.pop(); - Some(workspace_root_path.join("live-tests/chain_cache/client_rpc_tests_large")) -}); - -/// Path for the Zebra chain cache of The Public Testnet in the user's home directory. -pub static ZEBRAD_THE_PUB_TESTNET_CACHE_DIR: Lazy> = Lazy::new(|| { - let home_path = PathBuf::from(std::env::var("HOME").unwrap()); - Some(home_path.join(".cache/zebra")) -}); - -#[derive(Debug, PartialEq, Clone, Copy)] -/// Represents the type of validator to launch. -pub enum ValidatorKind { - /// Zcashd. - #[cfg(feature = "zcashd_support")] - Zcashd, - /// Zebrad. - Zebrad, -} - -impl ValidatorKind { - /// Default regtest activation heights for this validator kind. - /// - /// Centralises the per-kind selection so the choice (and its - /// `zcashd_support` gating) lives in one place instead of being matched at - /// every call site. - pub fn default_activation_heights(self) -> ActivationHeights { - match self { - #[cfg(feature = "zcashd_support")] - ValidatorKind::Zcashd => ActivationHeights::default(), - ValidatorKind::Zebrad => ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS, - } - } -} - -/// Config for validators. -pub enum ValidatorTestConfig { - /// Zcashd Config. - #[cfg(feature = "zcashd_support")] - ZcashdConfig(ZcashdConfig), - /// Zebrad Config. - ZebradConfig(zcash_local_net::validator::zebrad::ZebradConfig), -} - -/// Configuration data for Zaino Tests. -/// -/// Generic over the validator control plane `C` and the [`ValidatorConnectionMarker`] -/// `Conn` — the validator connection ([`Rpc`] or [`Direct`]) the single -/// [`NodeBackedIndexerService`] uses. -pub struct TestManager { - /// Control plane for a validator - pub local_net: C, - /// Data directory for the validator. - pub data_dir: PathBuf, - /// Network (chain) type: - pub network: NetworkKind, - /// Zebrad/Zcashd JsonRpc listen address. - pub full_node_rpc_listen_address: SocketAddr, - /// Zebrad/Zcashd gRpc listen address. - pub full_node_grpc_listen_address: SocketAddr, - /// Zaino Indexer JoinHandle. - pub zaino_handle: Option>>, - /// Zaino JsonRPC listen address. - pub zaino_json_rpc_listen_address: Option, - /// Zaino gRPC listen address. - pub zaino_grpc_listen_address: Option, - /// Service subscriber. - pub service_subscriber: Option, - /// JsonRPC server cookie dir. - pub json_server_cookie_dir: Option, - /// Selected validator connection marker. - _connection: PhantomData, -} - -/// Needed validator functionality that is not implemented in infrastructure -/// -/// TODO: Either move to Validator zcash_client_backend trait or document -/// why it should not be moved. -pub trait ValidatorExt: Validator + LogsToStdoutAndStderr { - /// Launch the validator, and return a validator config containing the - /// ports used by the validator, etc - fn launch_validator_and_return_config( - config: Self::Config, - ) -> impl Future> + Send + Sync; -} - -impl ValidatorExt for Zebrad { - async fn launch_validator_and_return_config( - config: ZebradConfig, - ) -> Result<(Self, ValidatorConfig), LaunchError> { - let zebrad = Zebrad::launch(config).await?; - let validator_config = ValidatorConfig { - validator_jsonrpc_listen_address: format!( - "{}:{}", - Ipv4Addr::LOCALHOST, - zebrad.rpc_listen_port() - ), - validator_grpc_listen_address: Some(format!( - "{}:{}", - Ipv4Addr::LOCALHOST, - zebrad.indexer_listen_port() - )), - validator_cookie_path: None, - validator_user: Some("xxxxxx".to_string()), - validator_password: Some("xxxxxx".to_string()), - }; - Ok((zebrad, validator_config)) - } -} - -#[cfg(feature = "zcashd_support")] -impl ValidatorExt for Zcashd { - async fn launch_validator_and_return_config( - config: Self::Config, - ) -> Result<(Self, ValidatorConfig), LaunchError> { - let zcashd = Zcashd::launch(config).await?; - let validator_config = ValidatorConfig { - validator_jsonrpc_listen_address: format!("{}:{}", Ipv4Addr::LOCALHOST, zcashd.port()), - validator_grpc_listen_address: None, - validator_cookie_path: None, - validator_user: Some("xxxxxx".to_string()), - validator_password: Some("xxxxxx".to_string()), - }; - Ok((zcashd, validator_config)) - } -} - -/// The pool that sessions funding wallets from coinbase mine to. Shielded -/// coinbase carries no 100-confirmation maturity rule (that rule covers only -/// transparent coinbase outputs), so a test that needs shielded start funds -/// mines one block per note it will spend instead of running the legacy -/// "mature 100 transparent blocks, then shield" ritual. -/// -/// Only sessions that profit from that trade opt in (wallet funding -/// fixtures): a shielded miner address costs a halo2 proof per block -/// template (~1–2 s/block), so sessions that fund nothing — or that mine -/// many blocks for other reasons — stay on [`default_mining_pool`]. -/// -/// This constant is the single upgrade point for shielded funding: when the -/// next shielded pool (ironwood) becomes minable, only this value changes. -pub const SHIELDED_FUNDING_POOL: MinerPool = MinerPool::Orchard; - -/// The pool a validator mines to when the session doesn't opt into -/// [`SHIELDED_FUNDING_POOL`]: transparent for zebrad (cheapest block -/// templates — a shielded miner address would cost a halo2 proof per block), -/// Orchard for zcashd (its historical setting; the cached launch reward -/// funds wallets without extra mining). -pub fn default_mining_pool(validator: &ValidatorKind) -> MinerPool { - if validator == &ValidatorKind::Zebrad { - MinerPool::Transparent - } else { - MinerPool::Orchard - } -} - -impl TestManager -where - C: ValidatorExt, - Conn: ValidatorConnectionMarker, -{ - /// Returns the service subscriber, panicking if zaino wasn't enabled at launch. - /// - /// Convenience for tests that always pass `enable_zaino: true` and want to - /// hand the subscriber to [`Self::generate_blocks_and_wait_for_tip`] without - /// repeating `.service_subscriber.as_ref().unwrap()` at every call site. - pub fn subscriber(&self) -> &NodeBackedIndexerServiceSubscriber { - self.service_subscriber - .as_ref() - .expect("TestManager::subscriber called but service_subscriber is None (zaino disabled at launch?)") - } - - #[cfg(test)] - pub(crate) fn grpc_socket_to_uri(&self) -> http::Uri { - http::Uri::builder() - .scheme("http") - .authority( - self.zaino_grpc_listen_address - .expect("grpc_listen_address should be set") - .to_string(), - ) - .path_and_query("/") - .build() - .unwrap() - } - - /// Launches zcash-local-net mining to - /// [`default_mining_pool`] for the validator. See - /// [`Self::launch_mining_to`], which this delegates to, for the parameter - /// contract; sessions that fund wallets from coinbase pass - /// [`SHIELDED_FUNDING_POOL`] to that method instead, and tests asserting - /// on a specific pool's coinbase footprint pin that pool. - pub async fn launch( - validator: &ValidatorKind, - network: Option, - activation_heights: Option, - chain_cache: Option, - enable_zaino: bool, - enable_zaino_jsonrpc_server: bool, - enable_clients: bool, - ) -> Result { - Self::launch_mining_to( - default_mining_pool(validator), - validator, - network, - activation_heights, - chain_cache, - enable_zaino, - enable_zaino_jsonrpc_server, - enable_clients, - ) - .await - } - - /// Launches zcash-local-net with coinbase paid to - /// `mine_to_pool`'s regtest miner address. - /// - /// Possible validators: Zcashd, Zebrad. - /// - /// On zebrad a shielded `mine_to_pool` maps to a unified miner address - /// whose receivers zebra tries in order orchard → sapling → transparent - /// per block, so with the default regtest activation heights (NU5 at - /// height 2) block 1's coinbase lands in the sapling receiver and later - /// blocks in the orchard receiver. - /// - /// If chain_cache is given a path the chain will be loaded. - /// - /// `enable_clients` must be `false`: zaino-testutils carries no zingolib, so - /// wallet lightclients are built by the separate e2e workspace from - /// the returned `TestManager`'s gRPC address. Passing `true` returns an error. - /// - /// TODO: Add TestManagerConfig struct and constructor methods of common test setups. - /// - /// TODO: Remove validator argument in favour of adding C::VALIDATOR associated const - #[instrument( - name = "TestManager::launch", - skip(activation_heights, chain_cache), - fields(validator = ?validator, network = ?network, mine_to_pool = ?mine_to_pool, enable_zaino, enable_clients) - )] - pub async fn launch_mining_to( - mine_to_pool: MinerPool, - validator: &ValidatorKind, - network: Option, - activation_heights: Option, - chain_cache: Option, - enable_zaino: bool, - enable_zaino_jsonrpc_server: bool, - enable_clients: bool, - ) -> Result { - #[cfg(feature = "zcashd_support")] - if (validator == &ValidatorKind::Zcashd) && (Conn::BACKEND == BackendType::Direct) { - return Err(std::io::Error::other( - "Cannot use the direct (ReadStateService) connection with zcashd.", - )); - } - zaino_common::logging::try_init(); - - let activation_heights = - activation_heights.unwrap_or_else(|| validator.default_activation_heights()); - let network_kind = network.unwrap_or(NetworkKind::Regtest); - // The validator is the single source of truth for activation heights - // (zaino#1076). zainod's config is a payload-free network kind: the - // fixture heights configure the validator alone, and zainod adopts - // the real schedule from the validator at spawn. Every - // custom-heights launch is therefore a standing regression test of - // that adoption. - let zaino_network_kind = match network_kind { - NetworkKind::Mainnet => Network::Mainnet, - NetworkKind::Testnet => Network::PubTestnet, - NetworkKind::Regtest => Network::Regtest, - }; - - if enable_clients && !enable_zaino { - return Err(std::io::Error::other( - "Cannot enable clients when zaino is not enabled.", - )); - } - - // Launch LocalNet: - - let mut config = C::Config::default(); - config.set_test_parameters( - mine_to_pool, - to_local_net_activation_heights(&activation_heights), - chain_cache.clone(), - ); - - debug!("[TEST] Launching validator"); - let (local_net, validator_settings) = C::launch_validator_and_return_config(config) - .await - .expect("to launch a default validator"); - let rpc_listen_port = local_net.get_port(); - debug!(rpc_port = rpc_listen_port, "[TEST] Validator launched"); - let full_node_rpc_listen_address = - SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), rpc_listen_port); - - let data_dir = local_net.data_dir().path().to_path_buf(); - let zaino_db_path = data_dir.join("zaino"); - - let zebra_db_path = match chain_cache { - Some(cache) => cache, - None => data_dir.clone(), - }; - - // Launch Zaino: - let ( - zaino_handle, - zaino_service_subscriber, - zaino_grpc_listen_address, - zaino_json_listen_address, - zaino_json_server_cookie_dir, - ) = if enable_zaino { - // Bind the indexer's listeners on 127.0.0.1:0 up-front and read the - // OS-assigned ports. Holding the open sockets until they are handed - // to the server (via launch_inner_with_listeners) closes the - // pick-a-port / bind-later race that portpicker left open. - let zaino_grpc_listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) - .expect("failed to bind Zaino gRPC listener on 127.0.0.1:0"); - let zaino_grpc_listen_address = zaino_grpc_listener - .local_addr() - .expect("local_addr on a bound listener is infallible"); - let zaino_json_listener = if enable_zaino_jsonrpc_server { - Some( - std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) - .expect("failed to bind Zaino JSON-RPC listener on 127.0.0.1:0"), - ) - } else { - None - }; - let zaino_json_listen_address = match &zaino_json_listener { - Some(listener) => listener - .local_addr() - .expect("local_addr on a bound listener is infallible"), - None => SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), - }; - debug!( - grpc_address = %zaino_grpc_listen_address, - json_address = %zaino_json_listen_address, - "[TEST] Launching Zaino indexer" - ); - let indexer_config = zainodlib::config::ZainodConfig { - backend: Conn::BACKEND, - json_server_settings: if enable_zaino_jsonrpc_server { - Some(JsonRpcServerConfig { - json_rpc_listen_address: zaino_json_listen_address, - cookie_dir: None, - }) - } else { - None - }, - grpc_settings: GrpcServerConfig { - listen_address: zaino_grpc_listen_address, - tls: None, - }, - validator_settings: validator_settings.clone(), - service: ServiceConfig::default(), - storage: StorageConfig { - cache: CacheConfig::default(), - database: DatabaseConfig { - path: zaino_db_path, - ..Default::default() - }, - }, - // The live suite exercises the built-in mempool bounds; a test - // that wants different ones sets them on its own config. - mempool: Default::default(), - ephemeral_finalised_state: false, - zebra_db_path, - network: zaino_network_kind, - donation_address: None, - metrics_endpoint: None, - }; - - let (handle, service_subscriber) = - Indexer::::launch_inner_with_listeners( - NodeBackedIndexerServiceConfig::try_from(indexer_config.clone()) - .expect("Failed to convert ZainodConfig to service config"), - indexer_config, - zaino_grpc_listener, - zaino_json_listener, - ) - .await - .unwrap(); - - ( - Some(handle), - Some(service_subscriber), - Some(zaino_grpc_listen_address), - Some(zaino_json_listen_address), - None, - ) - } else { - (None, None, None, None, None) - }; - // Wallet lightclients are built by the separate e2e workspace, - // not here — zaino-testutils carries no zingolib. Tests that need a - // faucet/recipient launch with `enable_clients: false` and build the - // clients themselves from `TestManager`'s gRPC address. - if enable_clients { - return Err(std::io::Error::other( - "enable_clients is unsupported in zaino-testutils: build lightclients in the \ - e2e workspace from TestManager's gRPC address instead.", - )); - } - let test_manager = Self { - local_net, - data_dir, - network: network_kind, - full_node_rpc_listen_address, - full_node_grpc_listen_address: validator_settings - .validator_grpc_listen_address - .as_ref() - .and_then(|addr| addr.parse().ok()) - .unwrap_or(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 0)), - zaino_handle, - zaino_json_rpc_listen_address: zaino_json_listen_address, - zaino_grpc_listen_address, - service_subscriber: zaino_service_subscriber, - json_server_cookie_dir: zaino_json_server_cookie_dir, - _connection: PhantomData, - }; - - test_manager.activate_nu5_nu6(enable_zaino).await; - Ok(test_manager) - } - - /// Waits for zaino to be ready, then generates a block to activate NU5/NU6. - /// - /// Must be called after construction so the indexer subscriber is live - /// before the first block-and-wait round-trip. - async fn activate_nu5_nu6(&self, enable_zaino: bool) { - // Generate an extra block to turn on NU5 and NU6, - // as they currently must be turned on at block height = 2. - match (enable_zaino, self.service_subscriber.as_ref()) { - (true, Some(subscriber)) => { - debug!("[TEST] Waiting for Zaino to be ready"); - poll_until_ready( - subscriber, - std::time::Duration::from_millis(100), - std::time::Duration::from_secs(30), - ) - .await; - self.generate_blocks_and_wait_for_tip(1, subscriber).await; - } - _ => { - self.local_net.generate_blocks(1).await.unwrap(); - } - } - - debug!("[TEST] Test environment ready"); - } - - /// Generate `n` blocks and wait for `pollable` to observe each new tip. - /// - /// Anything implementing [`PollableTip`] (a Zaino service subscriber, a - /// `NodeBackedChainIndexSubscriber`, or any future pollable) can be - /// passed. Fails fast (panics) if `pollable` reports non-live status. - pub async fn generate_blocks_and_wait_for_tip(&self, n: u32, pollable: &P) { - fn assert_live(pollable: &S, waiting_for: Option) { - if !pollable.is_live() { - let status = pollable.status(); - match waiting_for { - Some(h) => panic!( - "Pollable is not live while waiting for block {h} (status: {status:?})." - ), - None => panic!( - "Pollable is not live (status: {status:?}). \ - The backing validator may have crashed or become unreachable." - ), - } - } - } - - let chain_height = u64::from(self.local_net.get_chain_height().await); - let target_height = chain_height + n as u64; - let mut next_block_height = chain_height + 1; - let mut interval = tokio::time::interval(std::time::Duration::from_millis(50)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - interval.tick().await; - - // NOTE: readstate service seems to not be functioning correctly when generate multiple blocks at once and polling the latest block. - // commented out a fall back to `get_block` to query the cache directly if needed in the future. - // while indexer.get_block(zaino_proto::proto::service::BlockId { - // height: target_height, - // hash: vec![], - // }).await.is_err() - while pollable.tip_height().await < target_height { - assert_live(pollable, None); - if n == 0 { - interval.tick().await; - } else { - self.local_net.generate_blocks(1).await.unwrap(); - while pollable.tip_height().await != next_block_height { - assert_live(pollable, Some(next_block_height)); - interval.tick().await; - } - next_block_height += 1; - } - } - - // After height is reached, wait for readiness and measure if it adds time - if !pollable.is_ready() { - let start = std::time::Instant::now(); - poll_until_ready( - pollable, - std::time::Duration::from_millis(50), - std::time::Duration::from_secs(30), - ) - .await; - let elapsed = start.elapsed(); - if elapsed.as_millis() > 0 { - info!( - "Readiness wait after height poll took {:?} (height polling alone was insufficient)", - elapsed - ); - } - } - } - - /// Generate `n` blocks and wait for two chain-index subscribers to observe - /// the resulting tip. Blocks are mined while waiting on `mined_against`; - /// `then_synced` is afterwards polled (with no further block generation) - /// until it catches up to that same tip. - /// - /// For tests that maintain two independent chain indexes (e.g. a fetch and - /// a state backend, or a Zaino index and a validator-direct index) and must - /// see both in sync before querying them. - pub async fn generate_blocks_and_wait_for_tips( - &self, - n: u32, - mined_against: &A, - then_synced: &B, - ) { - self.generate_blocks_and_wait_for_tip(n, mined_against) - .await; - self.generate_blocks_and_wait_for_tip(0, then_synced).await; - } - - /// Generate `n` blocks in a single validator call, then wait once for - /// each pollable to catch up to the new tip. For large runs where no - /// test logic observes intermediate tips (coinbase maturation, - /// finalization depth): [`Self::generate_blocks_and_wait_for_tips`] - /// costs at least one indexer poll interval per block, this costs one - /// per run. The trade: the indexer ingests the run as a burst, and a - /// wedged indexer surfaces as a stalled tail wait rather than a - /// per-block liveness panic naming the missing height. - pub async fn generate_blocks_bulk_and_wait_for_tips( - &self, - n: u32, - mined_against: &A, - then_synced: &B, - ) { - self.local_net - .generate_blocks(n) - .await - .expect("validator failed to generate blocks"); - self.generate_blocks_and_wait_for_tips(0, mined_against, then_synced) - .await; - } - - /// Mine `n` blocks one at a time and, after each block (once both - /// pollables observe the new tip), run `check(i)` with the iteration - /// index `i` in `0..n`. The shared loop shape of the "compare - /// subscribers after every block" tests; callers with a single - /// subscriber pass it as both `mined_against` and `then_synced`. - pub async fn generate_blocks_and_check_each( - &self, - n: u32, - mined_against: &A, - then_synced: &B, - mut check: impl AsyncFnMut(u32), - ) { - for i in 0..n { - self.generate_blocks_and_wait_for_tips(1, mined_against, then_synced) - .await; - check(i).await; - } - } - - /// A raw JSON-RPC line to the backing validator, for tests that compare - /// Zaino's output against the validator's own. - /// - /// Answers are raw JSON: see [`ValidatorOracle`] for why the oracle side - /// deliberately does not go through a Zaino type. - pub async fn full_node_jsonrpc_connector(&self) -> ValidatorOracle { - ValidatorOracle::new(&self.full_node_rpc_listen_address.to_string()) - } - - /// Closes the TestManager. - pub async fn close(&mut self) { - if let Some(handle) = self.zaino_handle.take() { - handle.abort(); - } - } -} - -/// Handles from [`launch_state_and_fetch_services`]: a direct-connection -/// [`TestManager`] plus standalone `Rpc`- and `Direct`-connection -/// [`NodeBackedIndexerService`]s pointed at the same validator. The owned services -/// exist only to keep their subscribers alive — tests interact through the manager and -/// the subscribers. -/// -/// The `fetch_*` fields carry the `Rpc` (JSON-RPC) connection; the `state_*` fields carry -/// the `Direct` (`ReadStateService`) connection. -pub struct StateAndFetchServices { - /// The launched validator + Zaino test manager (direct connection). - pub test_manager: TestManager, - /// Owned `Rpc`-connection service; keeps `fetch_subscriber` alive. - pub fetch_service: NodeBackedIndexerService, - /// Subscriber to the standalone `Rpc`-connection service's chain index. - pub fetch_subscriber: NodeBackedIndexerServiceSubscriber, - /// Owned `Direct`-connection service; keeps `state_subscriber` alive. - pub state_service: NodeBackedIndexerService, - /// Subscriber to the standalone `Direct`-connection service's chain index. - pub state_subscriber: NodeBackedIndexerServiceSubscriber, -} - -impl StateAndFetchServices { - /// Mine `n` blocks and wait for both the fetch and state subscribers to - /// observe the new tip. - pub async fn generate_blocks_and_wait_for_tips(&self, n: u32) { - self.test_manager - .generate_blocks_and_wait_for_tips(n, &self.fetch_subscriber, &self.state_subscriber) - .await; - } -} - -/// Launch a state-backend [`TestManager`] alongside standalone [`FetchService`] -/// and [`StateService`] instances pointed at the same validator, returning the -/// services and their subscribers as a [`StateAndFetchServices`] bundle. -/// -/// This is the shared core of the `create_test_manager_and_services` test -/// harness used by both the clientless and e2e live-test partitions. -/// Wallet callers wrap this and additionally build lightclients from the -/// returned manager's gRPC address. -pub async fn launch_state_and_fetch_services( - validator: &ValidatorKind, - chain_cache: Option, - enable_zaino: bool, - network: Option, -) -> StateAndFetchServices { - launch_state_and_fetch_services_mining_to( - default_mining_pool(validator), - validator, - chain_cache, - enable_zaino, - network, - ) - .await -} - -/// [`launch_state_and_fetch_services`] with the miner's pool chosen by the -/// caller instead of [`default_mining_pool`]: [`SHIELDED_FUNDING_POOL`] for -/// sessions funding wallets from coinbase, or a pinned pool for tests whose -/// subject is the miner's coinbase footprint. -pub async fn launch_state_and_fetch_services_mining_to( - mine_to_pool: MinerPool, - validator: &ValidatorKind, - chain_cache: Option, - enable_zaino: bool, - network: Option, -) -> StateAndFetchServices { - let test_manager = TestManager::::launch_mining_to( - mine_to_pool, - validator, - network, - None, - chain_cache.clone(), - enable_zaino, - false, - false, - ) - .await - .unwrap(); - - let network_type = match network { - Some(NetworkKind::Mainnet) => { - println!("Waiting for validator to spawn.."); - tokio::time::sleep(std::time::Duration::from_millis(5000)).await; - Network::Mainnet - } - Some(NetworkKind::Testnet) => { - println!("Waiting for validator to spawn.."); - tokio::time::sleep(std::time::Duration::from_millis(5000)).await; - Network::PubTestnet - } - // The kind suffices: zainod adopts the schedule from the validator - // at spawn (zaino#1076). - _ => Network::Regtest, - }; - - test_manager.local_net.print_stdout(); - - let fetch_service = spawn_fetch_service( - test_manager.full_node_rpc_listen_address.to_string(), - None, - test_manager - .local_net - .data_dir() - .path() - .join("fetch-service-zaino"), - network_type, - ) - .await; - - let fetch_subscriber = fetch_service.get_subscriber().inner(); - - let state_chain_cache_dir = match chain_cache { - Some(dir) => dir, - None => test_manager.data_dir.clone(), - }; - - let state_service = - NodeBackedIndexerService::spawn(NodeBackedIndexerServiceConfig::new_direct( - zebra_state::Config { - cache_dir: state_chain_cache_dir, - ephemeral: false, - delete_old_database: true, - debug_stop_at_height: None, - debug_validity_check_interval: None, - should_backup_non_finalized_state: false, - debug_skip_non_finalized_state_backup_task: false, - }, - test_manager.full_node_rpc_listen_address.to_string(), - test_manager.full_node_grpc_listen_address, - false, - None, - None, - None, - ServiceConfig::default(), - StorageConfig { - database: DatabaseConfig { - path: test_manager - .local_net - .data_dir() - .path() - .to_path_buf() - .join("state-srvice-zaino"), - ..Default::default() - }, - ..Default::default() - }, - false, // ephemeral_finalised_state: tests use a persistent finalised DB - network_type, - None, - )) - .await - .unwrap(); - - let state_subscriber = state_service.get_subscriber().inner(); - - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - StateAndFetchServices { - test_manager, - fetch_service, - fetch_subscriber, - state_service, - state_subscriber, - } -} - -/// Spawn a standalone `Rpc`-connection [`NodeBackedIndexerService`] pointed at `rpc_url`, -/// storing its index under `db_path`. Shared by the launch helpers below. -async fn spawn_fetch_service( - rpc_url: String, - cookie_dir: Option, - db_path: PathBuf, - network: Network, -) -> NodeBackedIndexerService { - NodeBackedIndexerService::spawn(NodeBackedIndexerServiceConfig::new_rpc( - rpc_url, - cookie_dir, - None, - None, - ServiceConfig::default(), - StorageConfig { - database: DatabaseConfig { - path: db_path, - ..Default::default() - }, - ..Default::default() - }, - false, // ephemeral_finalised_state: tests use a persistent finalised DB - network, - None, - )) - .await - .unwrap() -} - -/// Handles from [`launch_zcashd_dual_fetch_services`]: a zcashd-backed -/// [`TestManager`] (JSON-RPC server enabled) plus two standalone fetch -/// services — one pointed at zcashd directly, one at Zaino's JSON-RPC -/// server — for tests that compare the two. The owned services exist only to -/// keep their subscribers alive. -#[cfg(feature = "zcashd_support")] -pub struct ZcashdDualFetchServices { - /// The launched zcashd + Zaino test manager. - pub test_manager: TestManager, - /// Owned zcashd-direct `Rpc` service; keeps `zcashd_subscriber` alive. - pub zcashd_fetch_service: NodeBackedIndexerService, - /// Subscriber whose answers come from zcashd directly. - pub zcashd_subscriber: NodeBackedIndexerServiceSubscriber, - /// Owned Zaino-pointed `Rpc` service; keeps `zaino_subscriber` alive. - pub zaino_fetch_service: NodeBackedIndexerService, - /// Subscriber whose answers come through Zaino's JSON-RPC server. - pub zaino_subscriber: NodeBackedIndexerServiceSubscriber, -} - -#[cfg(feature = "zcashd_support")] -#[allow(deprecated)] -impl ZcashdDualFetchServices { - /// Mine `n` blocks and wait for both the zaino and zcashd subscribers to - /// observe the new tip. - pub async fn generate_blocks_and_wait_for_tips(&self, n: u32) { - self.test_manager - .generate_blocks_and_wait_for_tips(n, &self.zaino_subscriber, &self.zcashd_subscriber) - .await; - } -} - -/// Launch a zcashd [`TestManager`] (with the JSON-RPC server enabled) alongside -/// two standalone [`FetchService`] instances — one pointed at zcashd directly, -/// one at Zaino's JSON-RPC server — for tests that compare the two, bundled as -/// a [`ZcashdDualFetchServices`]. -/// -/// Shared core of the `create_zcashd_test_manager_and_fetch_services` harness in -/// both live-test partitions (`clientless` and `e2e`). The `e2e` partition -/// wraps this and additionally builds lightclients from the returned manager's -/// gRPC address. -#[cfg(feature = "zcashd_support")] -#[allow(deprecated)] -pub async fn launch_zcashd_dual_fetch_services() -> ZcashdDualFetchServices { - launch_zcashd_dual_fetch_services_at(ActivationHeights::default()).await -} - -/// [`launch_zcashd_dual_fetch_services`] with the zcashd chain and both fetch -/// services pinned to `activation_heights`, for wallet clients (e.g. the devtool -/// wallet) whose compiled-in regtest heights differ from zcashd's defaults and -/// must be matched by the validator. -#[cfg(feature = "zcashd_support")] -#[allow(deprecated)] -pub async fn launch_zcashd_dual_fetch_services_at( - activation_heights: ActivationHeights, -) -> ZcashdDualFetchServices { - let test_manager = TestManager::::launch( - &ValidatorKind::Zcashd, - None, - Some(activation_heights), - None, - true, - true, - false, - ) - .await - .unwrap(); - - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - - let zcashd_fetch_service = spawn_fetch_service( - test_manager.full_node_rpc_listen_address.to_string(), - None, - test_manager - .local_net - .data_dir() - .path() - .join("zcashd-fetch-service-zaino"), - Network::Regtest, - ) - .await; - let zcashd_subscriber = zcashd_fetch_service.get_subscriber().inner(); - - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - - let zaino_fetch_service = spawn_fetch_service( - test_manager - .zaino_json_rpc_listen_address - .expect("zaino jsonrpc address must be active for these tests") - .to_string(), - test_manager.json_server_cookie_dir.clone(), - test_manager - .local_net - .data_dir() - .path() - .join("zaino-fetch-service-zaino"), - Network::Regtest, - ) - .await; - let zaino_subscriber = zaino_fetch_service.get_subscriber().inner(); - - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - - ZcashdDualFetchServices { - test_manager, - zcashd_fetch_service, - zcashd_subscriber, - zaino_fetch_service, - zaino_subscriber, - } -} - -/// Return a copy of `info` with its error timestamp cleared, so two `getinfo` -/// responses from different sources can be compared without spurious timestamp -/// differences. -pub fn get_info_with_zeroed_timestamp( - mut info: zaino_primitives::types::rpc::NodeInfo, -) -> zaino_primitives::types::rpc::NodeInfo { - info.errors_timestamp = None; - info -} - -/// Launch a fetch-backend [`TestManager`] and return it together with its own -/// service subscriber — the shared core of the `create_test_manager_and_fetch_service` -/// harness in both live-test partitions (`clientless` and `e2e`). The `e2e` -/// partition wraps this and builds lightclients from the returned manager's -/// gRPC address. -pub async fn launch_with_fetch_subscriber( - validator: &ValidatorKind, - chain_cache: Option, -) -> (TestManager, NodeBackedIndexerServiceSubscriber) { - launch_with_fetch_subscriber_mining_to::( - default_mining_pool(validator), - validator, - chain_cache, - ) - .await -} - -/// [`launch_with_fetch_subscriber`] with the miner's pool chosen by the -/// caller instead of [`default_mining_pool`]: [`SHIELDED_FUNDING_POOL`] for -/// sessions funding wallets from coinbase, or a pinned pool for tests whose -/// subject is the miner's coinbase footprint. -pub async fn launch_with_fetch_subscriber_mining_to( - mine_to_pool: MinerPool, - validator: &ValidatorKind, - chain_cache: Option, -) -> (TestManager, NodeBackedIndexerServiceSubscriber) { - let mut test_manager = TestManager::::launch_mining_to( - mine_to_pool, - validator, - None, - None, - chain_cache, - true, - false, - false, - ) - .await - .unwrap(); - let fetch_service_subscriber = test_manager.service_subscriber.take().unwrap(); - (test_manager, fetch_service_subscriber) -} - -impl Drop for TestManager { - fn drop(&mut self) { - debug!("[TEST] Shutting down test environment"); - if let Some(handle) = &self.zaino_handle { - debug!("[TEST] Aborting Zaino handle"); - handle.abort(); - }; - debug!("[TEST] Test environment shutdown complete"); - } -} - -/// Builds a client for creating RPC requests to the indexer/light-node -#[cfg(test)] -async fn build_client( - uri: http::Uri, -) -> Result, tonic::transport::Error> { - CompactTxStreamerClient::connect(uri).await -} - -#[cfg(test)] -mod launch_testmanager { - use super::*; - - /// Launch with no network/heights overrides and the optional servers off — - /// the smoke tests' shared launch shape; only the chain cache and the - /// zaino toggle vary. - async fn launch_minimal( - validator: &ValidatorKind, - chain_cache: Option, - enable_zaino: bool, - ) -> TestManager - where - C: ValidatorExt, - Conn: ValidatorConnectionMarker, - { - TestManager::::launch( - validator, - None, - None, - chain_cache, - enable_zaino, - false, - false, - ) - .await - .unwrap() - } - - #[cfg(feature = "zcashd_support")] - mod zcashd { - use zcash_local_net::validator::zcashd::Zcashd; - - use super::*; - - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn basic() { - let mut test_manager = - launch_minimal::(&ValidatorKind::Zcashd, None, false).await; - assert_eq!(2, (test_manager.local_net.get_chain_height().await)); - test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn generate_blocks() { - let mut test_manager = - launch_minimal::(&ValidatorKind::Zcashd, None, false).await; - assert_eq!(2, (test_manager.local_net.get_chain_height().await)); - test_manager.local_net.generate_blocks(1).await.unwrap(); - assert_eq!(3, (test_manager.local_net.get_chain_height().await)); - test_manager.close().await; - } - - #[ignore = "chain cache needs development"] - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn with_chain() { - let mut test_manager = launch_minimal::( - &ValidatorKind::Zcashd, - ZCASHD_CHAIN_CACHE_DIR.clone(), - false, - ) - .await; - assert_eq!(10, (test_manager.local_net.get_chain_height().await)); - test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn zaino() { - let mut test_manager = - launch_minimal::(&ValidatorKind::Zcashd, None, true).await; - - let _grpc_client = build_client(test_manager.grpc_socket_to_uri()) - .await - .unwrap(); - test_manager.close().await; - } - } - - mod zebrad { - use super::*; - - mod fetch_service { - - use zcash_local_net::validator::zebrad::Zebrad; - - use super::*; - - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn basic() { - let mut test_manager = - launch_minimal::(&ValidatorKind::Zebrad, None, false).await; - assert_eq!(2, (test_manager.local_net.get_chain_height().await)); - test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn generate_blocks() { - let mut test_manager = - launch_minimal::(&ValidatorKind::Zebrad, None, false).await; - assert_eq!(2, (test_manager.local_net.get_chain_height().await)); - test_manager.local_net.generate_blocks(1).await.unwrap(); - assert_eq!(3, (test_manager.local_net.get_chain_height().await)); - test_manager.close().await; - } - - #[ignore = "chain cache needs development"] - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn with_chain() { - let mut test_manager = launch_minimal::( - &ValidatorKind::Zebrad, - ZEBRAD_CHAIN_CACHE_DIR.clone(), - false, - ) - .await; - assert_eq!(52, (test_manager.local_net.get_chain_height().await)); - test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn zaino() { - let mut test_manager = - launch_minimal::(&ValidatorKind::Zebrad, None, true).await; - let _grpc_client = build_client(test_manager.grpc_socket_to_uri()) - .await - .unwrap(); - test_manager.close().await; - } - } - - mod state_service { - use super::*; - - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn basic() { - let mut test_manager = - launch_minimal::(&ValidatorKind::Zebrad, None, false).await; - assert_eq!(2, (test_manager.local_net.get_chain_height().await)); - test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn generate_blocks() { - let mut test_manager = - launch_minimal::(&ValidatorKind::Zebrad, None, false).await; - assert_eq!(2, (test_manager.local_net.get_chain_height().await)); - test_manager.local_net.generate_blocks(1).await.unwrap(); - assert_eq!(3, (test_manager.local_net.get_chain_height().await)); - test_manager.close().await; - } - - #[ignore = "chain cache needs development"] - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn with_chain() { - let mut test_manager = launch_minimal::( - &ValidatorKind::Zebrad, - ZEBRAD_CHAIN_CACHE_DIR.clone(), - false, - ) - .await; - assert_eq!(52, (test_manager.local_net.get_chain_height().await)); - test_manager.close().await; - } - - #[tokio::test(flavor = "multi_thread")] - #[allow(deprecated)] - pub(crate) async fn zaino() { - let mut test_manager = - launch_minimal::(&ValidatorKind::Zebrad, None, true).await; - let _grpc_client = build_client(test_manager.grpc_socket_to_uri()) - .await - .unwrap(); - test_manager.close().await; - } - } - } -} +pub use rpc::assert_rpc_parity; diff --git a/live-tests/zaino-testutils/src/rpc.rs b/live-tests/zaino-testutils/src/rpc.rs new file mode 100644 index 00000000..893e762a --- /dev/null +++ b/live-tests/zaino-testutils/src/rpc.rs @@ -0,0 +1,36 @@ +//! JSON-RPC parity helper shared by the parity tests. + +use anyhow::Result; +use serde_json::Value; +use ztest::prelude::JsonRpcClient; + +use crate::json::json_equal_ignoring; + +/// Call `method(params)` on two JSON-RPC sources and assert the responses +/// agree, after dropping `volatile` (dot-separated) paths. Returns the +/// first source's response so the caller can pluck fields from it for +/// follow-up assertions. +/// +/// Folds the `call`/`call`/`assert_eq!` triple duplicated across the +/// fetch/state/json_server parity tests into one call. `method` doubles +/// as the failure label. (Array responses needing order normalization — +/// e.g. `getrawmempool` — are compared inline at the call site instead.) +/// A mismatch is returned as `Err`, not panicked: see the module docs on +/// `json.rs` for why the composable form is the one that exists here. +pub async fn assert_rpc_parity( + method: &'static str, + params: &str, + a: &JsonRpcClient, + b: &JsonRpcClient, + volatile: &[&str], +) -> Result { + let params: Value = if params.is_empty() { + Value::Array(Vec::new()) + } else { + serde_json::from_str(params)? + }; + let av = a.call_value(method, params.clone()).await?; + let bv = b.call_value(method, params).await?; + json_equal_ignoring(method, av.clone(), bv, volatile)?; + Ok(av) +} diff --git a/live-tests/zaino-testutils/src/validator_oracle.rs b/live-tests/zaino-testutils/src/validator_oracle.rs deleted file mode 100644 index 8e7f818c..00000000 --- a/live-tests/zaino-testutils/src/validator_oracle.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! A raw JSON-RPC client for asking the validator directly. -//! -//! The live tests compare Zaino's answer against the validator's own. That -//! comparison is only meaningful if the two sides are reached independently, so -//! this goes straight to the validator over [`zaino_rpc`] and hands back the -//! JSON it sent — no Zaino type is involved in producing it. -//! -//! Answers are `serde_json::Value`. Deserializing them through a Zaino type -//! would make the oracle agree with Zaino by construction on exactly the -//! questions these tests exist to ask: field names, encodings, byte order. -//! Comparing rendered JSON on both sides tests what actually goes on the wire. - -use serde_json::Value; -use zaino_rpc::{RpcClient, RpcClientConfig}; - -/// A direct line to the validator's JSON-RPC interface. -pub struct ValidatorOracle { - client: RpcClient, -} - -impl ValidatorOracle { - /// Connects to a validator using the regtest test cookie credentials. - pub fn new(rpc_address: &str) -> Self { - Self { - client: RpcClient::new(RpcClientConfig { - url: format!("http://{rpc_address}"), - auth: Some(("xxxxxx".to_string(), "xxxxxx".to_string())), - ..Default::default() - }) - .expect("the oracle's RPC client is built from a fixed valid config"), - } - } - - /// Calls `method` and returns the validator's raw result. - /// - /// Panics on failure: an oracle that cannot reach the validator has nothing - /// to compare against, so the test has already failed. - pub async fn call(&self, method: &str, params: Vec) -> Value { - self.client - .call(method, params) - .await - .unwrap_or_else(|error| panic!("validator oracle call to {method} failed: {error}")) - } - - /// Calls `method` with no parameters. - pub async fn get(&self, method: &str) -> Value { - self.call(method, Vec::new()).await - } -} diff --git a/nix/package.nix b/nix/package.nix index d54cc545..7b37619a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -9,7 +9,7 @@ , withTls ? true , gitCommit ? "unknown" , gitBranch ? "unknown" -, rocksdb_8_11 +, rocksdb }: let @@ -49,15 +49,15 @@ let # stdenv.cc.cc.lib provides libstdc++.so.6 / libgcc_s.so.1 that # rocksdb's C++ code transitively needs at runtime. - buildInputs = [ rocksdb_8_11 stdenv.cc.cc.lib ]; + buildInputs = [ rocksdb stdenv.cc.cc.lib ]; env = { PROTOC = "${protobuf}/bin/protoc"; PROTOC_INCLUDE = "${protobuf}/include"; # Use nixpkgs' librocksdb instead of librocksdb-sys's bundled C++ compile. - ROCKSDB_LIB_DIR = "${rocksdb_8_11}/lib"; - ROCKSDB_INCLUDE_DIR = "${rocksdb_8_11}/include"; + ROCKSDB_LIB_DIR = "${rocksdb}/lib"; + ROCKSDB_INCLUDE_DIR = "${rocksdb}/include"; ZAINO_GIT_COMMIT_ID = gitCommit; ZAINO_GIT_BRANCH = gitBranch; diff --git a/packages/zaino-address/CHANGELOG.md b/packages/zaino-address/CHANGELOG.md index 9a211699..57546051 100644 --- a/packages/zaino-address/CHANGELOG.md +++ b/packages/zaino-address/CHANGELOG.md @@ -7,6 +7,25 @@ and this library adheres to Rust's notion of ## [Unreleased] +### Added +### Changed +### Deprecated +### Removed +### Fixed + +## [0.1.1] - 2026-09-11 + +### Added +### Changed +- `DEPRECATION_NOTICE` and the documentation no longer name zcashd, + whose support was removed from Zaino; the notice refers to the legacy + full node. +### Deprecated +### Removed +### Fixed + +## [0.1.0] - 2026-08-19 + ### Added - New crate. Zcash address classification for `validateaddress` and `z_validateaddress`: `validate_address`, `z_validate_address`, diff --git a/packages/zaino-address/Cargo.toml b/packages/zaino-address/Cargo.toml index df3ad39c..0e722964 100644 --- a/packages/zaino-address/Cargo.toml +++ b/packages/zaino-address/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "zaino-address" description = "Zcash address parsing and classification. Answers the questions Zaino's address-validation RPCs ask, without touching the chain." -version = "0.1.0" +version = "0.1.1" authors.workspace = true repository.workspace = true homepage.workspace = true diff --git a/packages/zaino-address/src/classify.rs b/packages/zaino-address/src/classify.rs index 05e34e77..30602854 100644 --- a/packages/zaino-address/src/classify.rs +++ b/packages/zaino-address/src/classify.rs @@ -55,7 +55,7 @@ pub fn z_validate_address(raw_address: String, params: &P) -> ZVa // The transparent arms echo the caller's string; the shielded arms // re-encode, because `convert_if_network` has already proved the address - // belongs to this network and the canonical encoding is what zcashd + // belongs to this network and the canonical encoding is what the legacy full node // reports. match parse_for_network(&raw_address, params) { Some(Address::Transparent(TransparentAddress::PublicKeyHash(_))) => { @@ -111,7 +111,7 @@ mod tests { const REGTEST: Net = Net(NetworkType::Regtest); const MAIN: Net = Net(NetworkType::Main); - // Canonical source: live-tests/clientless/src/lib.rs::rpc::json_rpc + // Canonical source: zaino-serve wire::address::tests::served_vectors // Tracked for DRY consolidation: https://github.com/zingolabs/zaino/issues/988 const TESTNET_P2PKH: &str = "tmVqEASZxBNKFTbmASZikGa5fPLkd68iJyx"; const TESTNET_P2SH: &str = "t2MjoXQ2iDrjG9QXNZNCY9io8ecN4FJYK1u"; @@ -180,7 +180,7 @@ mod tests { } /// `validateaddress` describes transparent addresses only. A well-formed - /// shielded address is reported invalid, matching zcashd. + /// shielded address is reported invalid, matching the legacy full node. #[test] fn validate_address_rejects_shielded() { assert_eq!( diff --git a/packages/zaino-address/src/lib.rs b/packages/zaino-address/src/lib.rs index f7a85a08..d150143c 100644 --- a/packages/zaino-address/src/lib.rs +++ b/packages/zaino-address/src/lib.rs @@ -20,7 +20,7 @@ //! # No serialization //! //! The types here are domain types. They carry raw key material as bytes, not -//! hex, and they do not derive `Serialize`. The zcashd-compatible JSON shapes +//! hex, and they do not derive `Serialize`. The legacy-compatible JSON shapes //! these RPCs return — field names, hex encoding, the `type` / `address_type` //! duplication — are the serving layer's concern and live in `zaino-serve`. //! diff --git a/packages/zaino-address/src/sapling.rs b/packages/zaino-address/src/sapling.rs index 2fee6756..58fd42e3 100644 --- a/packages/zaino-address/src/sapling.rs +++ b/packages/zaino-address/src/sapling.rs @@ -1,15 +1,15 @@ //! Sapling payment-address component extraction. /// Extracts the diversifier and pk_d bytes from a validated Sapling -/// [`sapling_crypto::PaymentAddress`], returning pk_d in zcashd's big-endian +/// [`sapling_crypto::PaymentAddress`], returning pk_d in the legacy full node's big-endian /// byte order. /// /// # Deprecation /// /// See [`DEPRECATION_NOTICE`](crate::DEPRECATION_NOTICE). This function exists /// to support the `z_validateaddress` RPC, which itself exists solely for -/// zcashd compatibility. The pk_d bytes are reversed from `sapling-crypto`'s -/// native little-endian representation to match zcashd's big-endian hex output. +/// the legacy full node compatibility. The pk_d bytes are reversed from `sapling-crypto`'s +/// native little-endian representation to match the legacy full node's big-endian hex output. /// /// # Precondition /// @@ -112,15 +112,15 @@ mod tests { } /// Verifies that our Sapling address parsing logic produces the same - /// diversifier and diversified transmission key (pk_d) bytes as zcashd's + /// diversifier and diversified transmission key (pk_d) bytes as the legacy full node's /// `z_validateaddress` RPC. /// /// # Guarantees /// /// - Exercises the production [`sapling_key_bytes`] function directly. - /// - The 11-byte diversifier matches the zcashd-derived test vector. + /// - The 11-byte diversifier matches the legacy-derived test vector. /// - The 32-byte pk_d (after the endian reversal inside - /// [`sapling_key_bytes`]) matches the zcashd-derived test vector. + /// [`sapling_key_bytes`]) matches the legacy-derived test vector. /// - If the upstream serialization changes, the failure message classifies /// the mismatch (endian swap, bit-reversal, chunk swap, or unrecognized) /// to aid diagnosis. @@ -128,7 +128,7 @@ mod tests { /// # Non-guarantees /// /// - Does not prove the test vector constants themselves are correct; they - /// were captured from zcashd and are trusted as ground truth. + /// were captured from the legacy full node and are trusted as ground truth. /// - Does not verify behavior for malformed Sapling addresses or addresses /// on other networks (mainnet, testnet). #[test] diff --git a/packages/zaino-address/src/validated.rs b/packages/zaino-address/src/validated.rs index 18630b7a..99f454c3 100644 --- a/packages/zaino-address/src/validated.rs +++ b/packages/zaino-address/src/validated.rs @@ -9,11 +9,11 @@ /// /// Emitted at runtime via `tracing::warn!` by [`super::z_validate_address`] and /// referenced from doc comments on the RPC methods that expose it. -pub const DEPRECATION_NOTICE: &str = "z_validateaddress is deprecated: delegating address validation to a non-client actor encourages information leakage. This service is only offered for bugwards compatibility with zcashd, and WILL BE REMOVED."; +pub const DEPRECATION_NOTICE: &str = "z_validateaddress is deprecated: delegating address validation to a non-client actor encourages information leakage. This service is only offered for bugwards compatibility with the legacy full node, and WILL BE REMOVED."; /// The result of the `validateaddress` RPC. /// -/// zcashd's `validateaddress` recognises transparent addresses only; a +/// the legacy full node's `validateaddress` recognises transparent addresses only; a /// well-formed shielded address is reported invalid rather than described. /// Zaino matches that. #[derive(Clone, Debug, PartialEq, Eq)] @@ -71,12 +71,12 @@ pub enum ZValidatedAddress { address: String, /// The diversifier `d`. diversifier: [u8; 11], - /// The diversified transmission key `pk_d`, in zcashd's big-endian + /// The diversified transmission key `pk_d`, in the legacy full node's big-endian /// byte order (see [`super::sapling_key_bytes`]). diversified_transmission_key: [u8; 32], }, - /// Unified address. zcashd reports no components for these, and neither + /// Unified address. the legacy full node reports no components for these, and neither /// does Zaino. Unified { /// The address, re-encoded for the queried network. diff --git a/packages/zaino-address/usage.md b/packages/zaino-address/usage.md index 786e4b11..b2395207 100644 --- a/packages/zaino-address/usage.md +++ b/packages/zaino-address/usage.md @@ -30,7 +30,7 @@ let result = z_validate_address(address_string, network); ``` Both return domain types (`ValidatedAddress`, `ZValidatedAddress`) with **no -serde**. The zcashd-shaped JSON — including the exact field sets, which differ +serde**. The legacy-shaped JSON — including the exact field sets, which differ per address kind — is `zaino-serve`'s `wire/address.rs`, per ADR-0009. ## What is deliberately not classified @@ -49,7 +49,7 @@ variant here and in `wire/address.rs` together. ## `ismine` is never emitted -zcashd's `ismine` field reports whether the *node's wallet* holds the key. +the legacy full node's `ismine` field reports whether the *node's wallet* holds the key. Zaino has no wallet, so it has no answer, and inventing `false` would be a claim rather than an omission. `wire/address.rs` pins this with a test. diff --git a/packages/zaino-bench/Cargo.toml b/packages/zaino-bench/Cargo.toml new file mode 100644 index 00000000..f491c643 --- /dev/null +++ b/packages/zaino-bench/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "zaino-bench" +description = "Benchmark harness for Zaino: initial-sync time, concurrent-connection load, and block serve rate." +authors = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +version = "0.1.0" +publish = false + +[[bin]] +name = "zaino-bench" +path = "src/main.rs" + +[dependencies] +zaino-proto = { workspace = true } +# For `ensure_default_crypto_provider`: the workspace enables reqwest's +# `rustls-no-provider`, so a provider must be installed explicitly before any +# rustls config is built, or `reqwest::Client::new` panics with "No provider +# set" (ADR-0006). +zaino-common = { workspace = true } + +# Runtime +tokio = { workspace = true, features = ["full"] } + +# Network / RPC +# TLS features match `zaino-serve`'s, so pointing the harness at an https +# endpoint adds no second rustls CryptoProvider path to the graph (ADR-0006). +tonic = { workspace = true, features = ["tls-native-roots", "tls-aws-lc"] } +reqwest = { workspace = true } + +# CLI +clap = { workspace = true, features = ["derive"] } + +# Utility +futures = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +# Test-only, so the `prometheus` feature is not unified into non-test builds of +# the workspace. `metric_names_match_zaino_state` asserts the metric names this +# crate scrapes are still the ones `zaino-state` emits. +zaino-state = { workspace = true, features = ["prometheus"] } diff --git a/packages/zaino-bench/src/chain.rs b/packages/zaino-bench/src/chain.rs new file mode 100644 index 00000000..2f857fd9 --- /dev/null +++ b/packages/zaino-bench/src/chain.rs @@ -0,0 +1,195 @@ +//! Incremental `prev_hash` link verification over a stream of compact blocks. +//! +//! Both the load generator and the serve-rate run need to know whether the +//! blocks they were served actually form a chain — a server that answers fast +//! with garbage is not answering. They differ only in how much detail they +//! report, so the checking itself lives here and each caller reads what it needs. + +use zaino_proto::proto::compact_formats::CompactBlock; + +use crate::grpc_client::copy_hash; + +/// A place where the served blocks stopped forming a chain. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ChainBreak { + /// The height at which the break was observed. + pub(crate) height: u64, + /// What was wrong, in operator-readable terms. + pub(crate) detail: String, +} + +/// Feed blocks in ascending height order; read the tally when the stream ends. +#[derive(Debug, Default)] +pub(crate) struct ChainVerifier { + prev_hash: Option<[u8; 32]>, + last_height: Option, + breaks: Vec, + hash_length_errors: usize, +} + +impl ChainVerifier { + /// A verifier with no blocks seen yet. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Checks `block` against the one before it and records any break. + pub(crate) fn push(&mut self, block: &CompactBlock) { + let height = block.height; + + if let Some(last) = self.last_height { + if height <= last { + self.record( + height, + format!("height {height} is not strictly after previous height {last} — blocks out of order"), + ); + } + } + self.last_height = Some(height); + + // A malformed hash is a protocol violation, not a chain break: count it + // separately and drop the link, so the next block is not also blamed. + let (Some(hash), Some(prev_hash)) = (copy_hash(&block.hash), copy_hash(&block.prev_hash)) + else { + self.hash_length_errors += 1; + self.prev_hash = None; + return; + }; + + if height == 0 && prev_hash != [0u8; 32] { + self.record( + 0, + "genesis block (height 0) must have prevHash = all-zeros".to_string(), + ); + } + + if let Some(expected) = self.prev_hash { + if prev_hash != expected { + self.record( + height, + format!( + "prevHash {} does not match previous block's hash {}", + hex(&prev_hash), + hex(&expected) + ), + ); + } + } + + self.prev_hash = Some(hash); + } + + /// Every break found so far, in the order they were observed. + pub(crate) fn breaks(&self) -> &[ChainBreak] { + &self.breaks + } + + /// How many blocks carried a hash field of the wrong length. + pub(crate) fn hash_length_errors(&self) -> usize { + self.hash_length_errors + } + + /// Breaks plus malformed hashes — the number the summary reports. + pub(crate) fn total_errors(&self) -> usize { + self.breaks.len() + self.hash_length_errors + } + + fn record(&mut self, height: u64, detail: String) { + self.breaks.push(ChainBreak { height, detail }); + } +} + +fn hex(bytes: &[u8; 32]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a linked run of `count` blocks starting at `start`, where block + /// `n`'s hash is `[n; 32]` — enough structure to exercise the link check + /// without constructing real blocks. + fn linked(start: u64, count: u64) -> Vec { + (start..start + count) + .map(|height| CompactBlock { + height, + hash: vec![height as u8; 32], + prev_hash: vec![height.wrapping_sub(1) as u8; 32], + ..CompactBlock::default() + }) + .collect() + } + + fn verify(blocks: &[CompactBlock]) -> ChainVerifier { + let mut verifier = ChainVerifier::new(); + for block in blocks { + verifier.push(block); + } + verifier + } + + #[test] + fn a_linked_run_has_no_errors() { + let verifier = verify(&linked(100, 10)); + assert_eq!(verifier.total_errors(), 0); + } + + #[test] + fn a_broken_link_is_one_break() { + let mut blocks = linked(100, 10); + blocks[5].prev_hash = vec![0xaa; 32]; + + let verifier = verify(&blocks); + assert_eq!(verifier.breaks().len(), 1); + assert_eq!(verifier.breaks()[0].height, 105); + assert_eq!(verifier.hash_length_errors(), 0); + } + + #[test] + fn a_short_hash_is_a_length_error_not_a_break() { + let mut blocks = linked(100, 10); + blocks[5].hash = vec![0xaa; 31]; + + let verifier = verify(&blocks); + assert_eq!(verifier.hash_length_errors(), 1); + // The block after the malformed one has no link to check against, so it + // is not blamed for the gap. + assert_eq!(verifier.breaks(), &[]); + } + + #[test] + fn genesis_must_have_a_zero_prev_hash() { + let block = CompactBlock { + height: 0, + hash: vec![1u8; 32], + prev_hash: vec![9u8; 32], + ..CompactBlock::default() + }; + + let verifier = verify(&[block]); + assert_eq!(verifier.breaks().len(), 1); + assert_eq!(verifier.breaks()[0].height, 0); + } + + #[test] + fn genesis_with_a_zero_prev_hash_is_clean() { + let block = CompactBlock { + height: 0, + hash: vec![1u8; 32], + prev_hash: vec![0u8; 32], + ..CompactBlock::default() + }; + + assert_eq!(verify(&[block]).total_errors(), 0); + } + + #[test] + fn out_of_order_heights_are_a_break() { + let mut blocks = linked(100, 3); + blocks.swap(1, 2); + + let verifier = verify(&blocks); + assert!(!verifier.breaks().is_empty()); + } +} diff --git a/packages/zaino-bench/src/concurrent.rs b/packages/zaino-bench/src/concurrent.rs new file mode 100644 index 00000000..f8fc3280 --- /dev/null +++ b/packages/zaino-bench/src/concurrent.rs @@ -0,0 +1,663 @@ +//! Concurrent load test — "how many connections can you support, and how fast +//! can you serve blocks?" +//! +//! Ported from the `zaino-admin concurrent-test` tool on the `hahn/store` +//! branch, with a connection sweep, tail percentiles, and a file-descriptor +//! preflight added: a single point sample cannot say where the knee is, a mean +//! hides the tail that clients actually feel, and a client-side `ulimit` is +//! easily mistaken for a server-side ceiling. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::Barrier; + +use clap::Args; +use tonic::transport::Channel; +use zaino_proto::proto::service::compact_tx_streamer_client::CompactTxStreamerClient; + +use crate::chain::ChainVerifier; +use crate::error::BenchError; +use crate::grpc_client; +use crate::stats::Summary; + +/// Hammer a server with N concurrent `GetBlockRange` clients and report how +/// many succeeded, how long they took, and the aggregate block throughput. +#[derive(Args)] +pub(super) struct ConcurrentArgs { + /// Server under test (e.g. "http://127.0.0.1:8137"). + #[arg(short, long)] + server: String, + + /// Lower bound of the height pool connections draw their ranges from. + #[arg(long)] + start_height: u64, + + /// Upper bound (inclusive) of the height pool. + #[arg(long)] + end_height: u64, + + /// Blocks each connection fetches. + #[arg(short, long, default_value = "1000")] + blocks: u64, + + /// Concurrent connections. Ignored when `--sweep` is given. + #[arg(short, long, default_value = "1000")] + connections: usize, + + /// Run one round per value and print a comparison table, e.g. + /// `--sweep 100,250,500,1000,2000`. This is what locates the knee — the + /// point where the success rate starts falling off. + #[arg(long, value_delimiter = ',')] + sweep: Vec, + + /// Milliseconds between spawning each connection, to avoid a SYN burst that + /// would measure the kernel's accept backlog rather than the server. + /// + /// An upper bound only: the actual gap is whichever is smaller, this or an + /// even spread across `--spawn-window-ms`. See [`spawn_gap`]. + #[arg(long, default_value = "1")] + spawn_delay_ms: u64, + + /// Longest the whole round may take to bring every connection up. + /// + /// At high connection counts a fixed per-connection delay stops measuring + /// concurrency: 10,000 connections at 1ms apart take 10s to establish, by + /// which time the first ones have finished and the peak overlap is nowhere + /// near 10,000. Capping the total ramp keeps the round a concurrency + /// measurement instead of a throughput one. + #[arg(long, default_value = "2000")] + spawn_window_ms: u64, + + /// Seconds to settle between sweep rounds, so one round's teardown does not + /// land on the next round's connects. + #[arg(long, default_value = "5")] + settle_secs: u64, + + /// Print per-connection timing. + #[arg(short, long)] + verbose: bool, +} + +/// What one connection did. +struct ConnectionResult { + index: usize, + range_start: u64, + range_end: u64, + blocks: usize, + connect_elapsed: Duration, + fetch_elapsed: Duration, + error: Option, + chain_breaks: usize, +} + +impl ConnectionResult { + fn total_elapsed(&self) -> Duration { + self.connect_elapsed + self.fetch_elapsed + } +} + +/// The headline numbers for one round, kept so the sweep can tabulate them. +struct RoundSummary { + connections: usize, + succeeded: usize, + wall_elapsed: Duration, + blocks_fetched: usize, + chain_breaks: usize, + fetch: Option, +} + +impl RoundSummary { + fn success_rate(&self) -> f64 { + if self.connections == 0 { + return 0.0; + } + self.succeeded as f64 / self.connections as f64 * 100.0 + } + + fn aggregate_throughput(&self) -> f64 { + let seconds = self.wall_elapsed.as_secs_f64(); + if seconds <= 0.0 { + return 0.0; + } + self.blocks_fetched as f64 / seconds + } +} + +/// Longest a single connection may take to establish before the round gives up +/// on it. Bounds the barrier: without it one hung dial stalls the whole round. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(60); + +pub(super) async fn run(args: ConcurrentArgs) -> Result<(), BenchError> { + let pool_size = pool_size(args.start_height, args.end_height)?; + + if args.blocks == 0 { + return Err(BenchError::Args("--blocks must be at least 1".into())); + } + if args.blocks > pool_size { + return Err(BenchError::Args(format!( + "--blocks {} is larger than the available pool {}..={} ({pool_size} blocks)", + args.blocks, args.start_height, args.end_height, + ))); + } + + let rounds = if args.sweep.is_empty() { + vec![args.connections] + } else { + args.sweep.clone() + }; + if rounds.contains(&0) { + return Err(BenchError::Args( + "connection counts must be at least 1".into(), + )); + } + + eprintln!("Server: {}", args.server); + eprintln!( + "Pool: {}..={} ({pool_size} blocks)", + args.start_height, args.end_height + ); + let max_connections = rounds.iter().copied().max().unwrap_or(0); + if let Some(warning) = + open_file_limit().and_then(|limit| fd_limit_warning(limit, max_connections)) + { + eprintln!(); + eprintln!(" ⚠ {warning}"); + } + eprintln!(); + + let mut summaries = Vec::with_capacity(rounds.len()); + for (position, connections) in rounds.iter().copied().enumerate() { + if position > 0 && args.settle_secs > 0 { + tokio::time::sleep(Duration::from_secs(args.settle_secs)).await; + } + summaries.push(round(&args, pool_size, connections).await); + } + + if summaries.len() > 1 { + print_sweep_table(&summaries, args.blocks); + } + + // A sweep is *meant* to push past the knee, so partial failure is a result, + // not an error. Zero successes anywhere means the harness never reached the + // server at all — that is a failed run, and it should exit non-zero. + if summaries.iter().all(|summary| summary.succeeded == 0) { + return Err(BenchError::AllConnectionsFailed(args.server.clone())); + } + + Ok(()) +} + +async fn round(args: &ConcurrentArgs, pool_size: u64, connections: usize) -> RoundSummary { + let ranges = distribute_ranges(args.start_height, pool_size, args.blocks, connections); + let overlap = args.blocks.saturating_mul(connections as u64) > pool_size; + + eprintln!("──────────────────────────────────────────"); + eprintln!( + "{connections} connections × {} blocks each{}", + args.blocks, + if overlap { " (ranges overlap)" } else { "" } + ); + eprintln!(); + + let gap = spawn_gap(args.spawn_delay_ms, args.spawn_window_ms, connections); + eprintln!( + " ramp: {:.0}µs between connects, {:.1}s to bring all {connections} up", + gap.as_secs_f64() * 1e6, + gap.as_secs_f64() * connections as f64 + ); + + // `connections + 1`: every connection, plus this task, so the round's + // wall-clock starts the instant the last socket is established rather than + // when the first one was created. The ramp is then a setup cost outside the + // measurement, not part of it. + let barrier = Arc::new(Barrier::new(connections + 1)); + let established = Arc::new(AtomicUsize::new(0)); + + let mut handles = Vec::with_capacity(connections); + for (index, (range_start, range_end)) in ranges.into_iter().enumerate() { + let server = args.server.clone(); + let barrier = Arc::clone(&barrier); + let established = Arc::clone(&established); + handles.push(tokio::spawn(async move { + fetch_one(index, &server, range_start, range_end, barrier, established).await + })); + if !gap.is_zero() { + tokio::time::sleep(gap).await; + } + } + + barrier.wait().await; + // Report what actually connected, not what was asked for: a failed dial + // still reaches the barrier (so one failure cannot strand the round), so + // "all N" would be a lie exactly when the server is refusing connections. + let established = established.load(Ordering::Relaxed); + if established == connections { + eprintln!(" all {connections} connected; starting fetch"); + } else { + eprintln!( + " {established}/{connections} connected ({} failed to dial); starting fetch", + connections - established + ); + } + eprintln!(); + let wall_start = Instant::now(); + + // A task that panicked has no timing to contribute; dropping it here keeps + // it out of the statistics, and it still shows in the success/total counts. + let results: Vec = futures::future::join_all(handles) + .await + .into_iter() + .filter_map(Result::ok) + .collect(); + + let wall_elapsed = wall_start.elapsed(); + + if args.verbose { + for result in &results { + eprintln!("{}", verbose_line(result)); + } + eprintln!(); + } + + summarise(connections, args.blocks, wall_elapsed, &results) +} + +/// Connect, wait for every other connection, then fetch and verify. +/// +/// The barrier is what makes the round a concurrency measurement. Without it a +/// connection whose work is shorter than the spawn ramp finishes before its +/// successors are even created, so the number open at once never approaches the +/// nominal count — the round silently becomes a throughput test. Holding every +/// connection open until all of them have connected means the fetch phase starts +/// with exactly `connections` sockets established, whatever the work costs. +/// +/// A connection that fails to connect still waits, so one failure cannot strand +/// the rest; it just skips the fetch. +async fn fetch_one( + index: usize, + server: &str, + range_start: u64, + range_end: u64, + barrier: Arc, + established: Arc, +) -> ConnectionResult { + let connect_start = Instant::now(); + // Bound the connect so a single hung dial cannot hold the barrier — and + // with it the whole round — open indefinitely. + let client = + match tokio::time::timeout(CONNECT_TIMEOUT, grpc_client::connect_eager(server)).await { + Ok(result) => result.map_err(|error| error.to_string()), + Err(_) => Err(format!( + "connect timed out after {}s", + CONNECT_TIMEOUT.as_secs() + )), + }; + let connect_elapsed = connect_start.elapsed(); + if client.is_ok() { + established.fetch_add(1, Ordering::Relaxed); + } + + barrier.wait().await; + + let fetch_start = Instant::now(); + let failed = |error: String| ConnectionResult { + index, + range_start, + range_end, + blocks: 0, + connect_elapsed, + fetch_elapsed: fetch_start.elapsed(), + error: Some(error), + chain_breaks: 0, + }; + + let mut client: CompactTxStreamerClient = match client { + Ok(client) => client, + Err(error) => return failed(error), + }; + + match grpc_client::fetch_block_range(&mut client, range_start, range_end).await { + Ok(blocks) => { + let mut verifier = ChainVerifier::new(); + for block in &blocks { + verifier.push(block); + } + ConnectionResult { + index, + range_start, + range_end, + blocks: blocks.len(), + connect_elapsed, + fetch_elapsed: fetch_start.elapsed(), + error: None, + chain_breaks: verifier.total_errors(), + } + } + Err(error) => failed(error.to_string()), + } +} + +/// Spreads `connections` windows of `blocks` evenly across the pool. +/// +/// The first window starts at `start_height` and the last ends at the pool's +/// end. When `blocks × connections` exceeds the pool the windows overlap, which +/// is fine: this is a load test, not a coverage sweep. +fn distribute_ranges( + start_height: u64, + pool_size: u64, + blocks: u64, + connections: usize, +) -> Vec<(u64, u64)> { + let end_height = start_height + pool_size - 1; + let step = if connections > 1 { + (pool_size - blocks) as f64 / (connections - 1) as f64 + } else { + 0.0 + }; + + (0..connections) + .map(|index| { + let range_start = start_height + (step * index as f64).round() as u64; + (range_start, (range_start + blocks - 1).min(end_height)) + }) + .collect() +} + +fn pool_size(start_height: u64, end_height: u64) -> Result { + end_height + .checked_sub(start_height) + .map(|span| span + 1) + .ok_or_else(|| { + BenchError::Args(format!( + "--end-height {end_height} is below --start-height {start_height}" + )) + }) +} + +fn summarise( + connections: usize, + blocks_each: u64, + wall_elapsed: Duration, + results: &[ConnectionResult], +) -> RoundSummary { + let ok: Vec<&ConnectionResult> = results.iter().filter(|r| r.error.is_none()).collect(); + let failed = connections - ok.len(); + + let seconds = |extract: fn(&ConnectionResult) -> Duration| -> Vec { + ok.iter().map(|r| extract(r).as_secs_f64()).collect() + }; + let connect = Summary::new(&seconds(|r| r.connect_elapsed)); + let fetch = Summary::new(&seconds(|r| r.fetch_elapsed)); + let total = Summary::new(&seconds(ConnectionResult::total_elapsed)); + + let summary = RoundSummary { + connections, + succeeded: ok.len(), + wall_elapsed, + blocks_fetched: ok.iter().map(|r| r.blocks).sum(), + chain_breaks: ok.iter().map(|r| r.chain_breaks).sum(), + fetch, + }; + + eprintln!("Results:"); + eprintln!( + " Connections: {}/{failed}/{connections} (success / failed / total) — {:.1}% success", + summary.succeeded, + summary.success_rate(), + ); + eprintln!( + " Per connection: {blocks_each} blocks ({} total fetched)", + summary.blocks_fetched, + ); + eprintln!( + " Chain breaks: {}{}", + summary.chain_breaks, + if summary.chain_breaks > 0 { " ⚠" } else { "" } + ); + eprintln!( + " Wall-clock time: {:.2}s", + summary.wall_elapsed.as_secs_f64() + ); + + if let (Some(connect), Some(fetch), Some(total)) = (connect, fetch, total) { + eprintln!(); + eprintln!("{}", connect.line("Connect time (s):")); + eprintln!("{}", fetch.line("Fetch time (s):")); + eprintln!("{}", total.line("Per-connection total (s):")); + eprintln!(); + eprintln!( + " Aggregate throughput: {:.0} blocks/s across {connections} connections", + summary.aggregate_throughput(), + ); + if total.mean > 0.0 { + eprintln!( + " Per-connection throughput: {:.0} blocks/s (mean)", + blocks_each as f64 / total.mean, + ); + } + } else { + eprintln!(); + eprintln!(" All {connections} connections failed — no timings to report."); + if let Some(first) = results.iter().find_map(|r| r.error.as_deref()) { + eprintln!(" First error: {first}"); + } + } + eprintln!(); + + summary +} + +fn print_sweep_table(summaries: &[RoundSummary], blocks_each: u64) { + eprintln!("══════════════════════════════════════════"); + eprintln!(" Connection Sweep — {blocks_each} blocks per connection"); + eprintln!("══════════════════════════════════════════"); + eprintln!( + " {:>6} {:>9} {:>8} {:>10} {:>12} {:>8}", + "conns", "success", "wall (s)", "mean fetch", "blocks/s", "breaks" + ); + + for summary in summaries { + eprintln!( + " {:>6} {:>8.1}% {:>8.2} {:>10} {:>12.0} {:>8}", + summary.connections, + summary.success_rate(), + summary.wall_elapsed.as_secs_f64(), + summary + .fetch + .map(|fetch| format!("{:.3}s", fetch.mean)) + .unwrap_or_else(|| "—".to_string()), + summary.aggregate_throughput(), + summary.chain_breaks, + ); + } + eprintln!(); + eprintln!(" The supported connection count is the last row still at 100% success."); +} + +fn verbose_line(result: &ConnectionResult) -> String { + match &result.error { + Some(error) => format!( + " Connection {:>4}: {}..={} → ERROR (connect {:.2}s) — {error}", + result.index, + result.range_start, + result.range_end, + result.connect_elapsed.as_secs_f64(), + ), + None => format!( + " Connection {:>4}: {}..={} → {} blocks, connect {:.2}s, fetch {:.2}s{}", + result.index, + result.range_start, + result.range_end, + result.blocks, + result.connect_elapsed.as_secs_f64(), + result.fetch_elapsed.as_secs_f64(), + if result.chain_breaks > 0 { + format!(", {} chain break(s) ⚠", result.chain_breaks) + } else { + String::new() + }, + ), + } +} + +/// Warns when this process cannot open enough sockets for the largest round. +/// +/// Without this, a client-side `ulimit -n` of 1024 reads as "the server tops out +/// near 1000 connections", which is the wrong answer to the question being asked. +/// Gap between two connects: the per-connection delay, or an even spread across +/// the ramp window, whichever is smaller. +/// +/// Sub-millisecond by design at high counts — 10,000 connections across a 2s +/// window is 200µs apart, which still avoids a SYN burst but keeps every +/// connection up inside the window rather than 10s later. +fn spawn_gap(delay_ms: u64, window_ms: u64, connections: usize) -> Duration { + let requested = Duration::from_millis(delay_ms); + let connections = connections.max(1) as u32; + let spread = Duration::from_millis(window_ms) / connections; + requested.min(spread) +} + +fn fd_limit_warning(limit: u64, max_connections: usize) -> Option { + let needed = (max_connections as u64).saturating_mul(2); + + (limit < needed).then(|| { + format!( + "open-file limit is {limit}, but {max_connections} connections need roughly \ + {needed}. Raise it (`ulimit -n {needed}`) or the ceiling you measure will \ + be this client's, not the server's." + ) + }) +} + +/// This process's soft `RLIMIT_NOFILE`, read from procfs. +/// +/// Procfs rather than a `libc` dependency: the harness only needs to *warn*, and +/// on a platform without procfs it simply declines to. +fn open_file_limit() -> Option { + let limits = std::fs::read_to_string("/proc/self/limits").ok()?; + parse_open_file_limit(&limits) +} + +fn parse_open_file_limit(limits: &str) -> Option { + let line = limits + .lines() + .find(|line| line.starts_with("Max open files"))?; + line.split_whitespace() + .find_map(|field| field.parse::().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ranges_span_the_pool_end_to_end() { + let ranges = distribute_ranges(1_000, 1_000, 100, 4); + assert_eq!(ranges.len(), 4); + assert_eq!(ranges[0], (1_000, 1_099)); + assert_eq!(ranges[3], (1_900, 1_999)); + assert!(ranges.windows(2).all(|pair| pair[0].0 <= pair[1].0)); + } + + #[test] + fn a_single_connection_starts_at_the_pool_start() { + assert_eq!(distribute_ranges(1_000, 1_000, 100, 1), [(1_000, 1_099)]); + } + + #[test] + fn ranges_overlap_rather_than_overrun_when_demand_exceeds_the_pool() { + let pool_start = 1_000; + let pool_size = 500; + let ranges = distribute_ranges(pool_start, pool_size, 400, 10); + + assert_eq!(ranges.len(), 10); + assert!( + ranges.iter().all(|&(_, end)| end < pool_start + pool_size), + "no range may run past the pool: {ranges:?}" + ); + assert!( + ranges.windows(2).any(|pair| pair[1].0 <= pair[0].1), + "windows should overlap once demand exceeds the pool: {ranges:?}" + ); + } + + #[test] + fn every_range_holds_the_requested_block_count() { + for &(start, end) in &distribute_ranges(1_000, 10_000, 250, 16) { + assert_eq!(end - start + 1, 250); + } + } + + #[test] + fn an_inverted_pool_is_rejected() { + assert!(pool_size(2_000, 1_000).is_err()); + assert_eq!(pool_size(1_000, 1_000).ok(), Some(1)); + assert_eq!(pool_size(1_000, 1_999).ok(), Some(1_000)); + } + + /// At low counts the per-connection delay governs and the ramp is short. At + /// high counts the window governs, so the round stays a concurrency + /// measurement: 10,000 connections come up inside the window rather than + /// 10s apart, which is what a fixed 1ms delay would have done. + #[test] + fn the_spawn_ramp_is_bounded_by_the_window() { + let ramp = + |connections: usize| spawn_gap(1, 2000, connections).as_secs_f64() * connections as f64; + + assert_eq!( + spawn_gap(1, 2000, 100), + Duration::from_millis(1), + "at 100 connections the per-connection delay is the smaller bound" + ); + assert!( + ramp(100) < 0.2, + "and the whole ramp is a fraction of a second" + ); + + assert_eq!( + spawn_gap(1, 2000, 10_000), + Duration::from_micros(200), + "at 10,000 the window is the smaller bound: 2s / 10,000" + ); + assert!( + (ramp(10_000) - 2.0).abs() < 0.01, + "so the ramp is the window, not the 10s a fixed 1ms delay would give" + ); + } + + /// `Barrier::new(connections + 1)` would panic on a zero-party barrier, and + /// the gap arithmetic divides by the connection count. + #[test] + fn a_degenerate_connection_count_is_handled() { + assert_eq!(spawn_gap(1, 2000, 0), Duration::from_millis(1)); + assert_eq!(spawn_gap(1, 2000, 1), Duration::from_millis(1)); + } + + /// A zero window means "no ramp": spawn as fast as the loop allows. + #[test] + fn a_zero_window_removes_the_ramp() { + assert!(spawn_gap(1, 0, 100).is_zero()); + } + + #[test] + fn the_fd_warning_fires_only_below_the_requirement() { + let limits = "Max open files 1024 4096 files\n"; + assert_eq!(parse_open_file_limit(limits), Some(1024)); + + // 1024 fds cannot cover 1000 connections, but comfortably covers 100. + let warning = fd_limit_warning(1024, 1000).expect("1024 fds is short of 2000"); + assert!( + warning.contains("2000"), + "warning should name the requirement: {warning}" + ); + assert!(fd_limit_warning(1024, 100).is_none()); + } + + #[test] + fn an_unparseable_limits_file_yields_no_limit() { + assert_eq!(parse_open_file_limit("Max open files unlimited"), None); + assert_eq!(parse_open_file_limit(""), None); + } +} diff --git a/packages/zaino-bench/src/error.rs b/packages/zaino-bench/src/error.rs new file mode 100644 index 00000000..e0dac43d --- /dev/null +++ b/packages/zaino-bench/src/error.rs @@ -0,0 +1,59 @@ +//! Error type for the benchmark harness. + +use std::time::Duration; + +/// Every way a benchmark run can fail. +#[derive(Debug, thiserror::Error)] +pub(crate) enum BenchError { + /// An argument combination the harness cannot act on. + #[error("invalid arguments: {0}")] + Args(String), + + /// The gRPC endpoint could not be reached or refused a call. + #[error(transparent)] + Grpc(#[from] crate::grpc_client::Error), + + /// The Prometheus scrape endpoint could not be reached. + #[error("failed to scrape {url}: {source}")] + Scrape { + /// The endpoint that was scraped. + url: String, + /// The underlying transport failure. + source: reqwest::Error, + }, + + /// zainod is serving `/metrics`, but without a metric the harness needs. + /// Most often this means zainod was built without `--features prometheus`, + /// or has not yet emitted its first sync sample. + #[error( + "metric `{0}` is absent from the scrape — is zainod built with \ + `--features prometheus`, and has its sync loop started?" + )] + MissingMetric(&'static str), + + /// `finalized_height` did not advance within the stall timeout. + #[error("sync stalled: finalized height held at {height} for {}s", .timeout.as_secs())] + SyncStalled { + /// The height sync was stuck at. + height: u64, + /// How long it was stuck for. + timeout: Duration, + }, + + /// Not one connection reached the server, in any round. + #[error("no connection to {0} succeeded in any round")] + AllConnectionsFailed(String), + + /// The chain served over gRPC does not link up. + #[error("chain is invalid: {0} error(s) found")] + InvalidChain(usize), + + /// Writing the CSV sample log failed. + #[error("failed to write CSV to {path}: {source}")] + Csv { + /// The path that was being written. + path: String, + /// The underlying IO failure. + source: std::io::Error, + }, +} diff --git a/packages/zaino-bench/src/grpc_client.rs b/packages/zaino-bench/src/grpc_client.rs new file mode 100644 index 00000000..a682b41c --- /dev/null +++ b/packages/zaino-bench/src/grpc_client.rs @@ -0,0 +1,146 @@ +//! Thin tonic client helpers over `CompactTxStreamer`. +//! +//! The workspace has no reusable public client helper — `zaino-testutils`' +//! `build_client` is `#[cfg(test)]` and `tonic` is only a dev-dependency there — +//! so the load generator carries its own. + +use futures::TryStreamExt; +use tonic::transport::{Channel, Endpoint}; +use tonic::Status; +use zaino_proto::proto::{ + compact_formats::CompactBlock, + service::{ + compact_tx_streamer_client::CompactTxStreamerClient, BlockId, BlockRange, ChainSpec, + }, +}; + +/// A failure reaching, or talking to, the server under test. +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + /// The endpoint URL was malformed, or the TCP/TLS connection failed. + #[error("failed to connect to {url}: {source}")] + Connect { + /// The endpoint that was dialled. + url: String, + /// The underlying transport failure. + source: Box, + }, + + /// The server accepted the connection but rejected the call. + #[error("gRPC call failed: {0}")] + Grpc(#[from] Status), +} + +/// Dials `url` and completes the connection before returning. +/// +/// The load generator needs the connect step to be a *measurable* phase, which +/// rules out tonic's lazy channel: a lazy connect would fold the handshake into +/// the first request and inflate the fetch timing instead. +pub(crate) async fn connect_eager(url: &str) -> Result, Error> { + let endpoint = endpoint(url)?.keep_alive_while_idle(true); + + let channel = match endpoint.uri().scheme_str() { + Some("https") => tls(&endpoint, url)?.connect().await, + _ => endpoint.connect().await, + } + .map_err(|source| connect_error(url, source))?; + + Ok(CompactTxStreamerClient::new(channel)) +} + +/// Returns the chain tip height the server is currently advertising. +pub(crate) async fn get_latest_height( + client: &mut CompactTxStreamerClient, +) -> Result { + let response = client.get_latest_block(ChainSpec::default()).await?; + Ok(response.into_inner().height) +} + +/// Opens a `GetBlockRange` stream over `start..=end`. +/// +/// Returns the raw stream rather than a collected `Vec` so a caller measuring +/// serve rate can time each block as it arrives; [`fetch_block_range`] collects +/// for callers that only need the total. +pub(crate) async fn block_range_stream( + client: &mut CompactTxStreamerClient, + start: u64, + end: u64, +) -> Result, Error> { + let response = client.get_block_range(block_range(start, end)).await?; + Ok(response.into_inner()) +} + +/// Streams `start..=end` to completion, returning the blocks sorted by height. +pub(crate) async fn fetch_block_range( + client: &mut CompactTxStreamerClient, + start: u64, + end: u64, +) -> Result, Error> { + let stream = block_range_stream(client, start, end).await?; + let mut blocks: Vec = stream.try_collect().await?; + blocks.sort_by_key(|block| block.height); + Ok(blocks) +} + +/// Copies a wire hash into a fixed array, rejecting any other length. +/// +/// A wrong length is a protocol violation rather than a chain break, so callers +/// count it separately; returning `Option` keeps that decision at the call site. +pub(crate) fn copy_hash(bytes: &[u8]) -> Option<[u8; 32]> { + <[u8; 32]>::try_from(bytes).ok() +} + +fn block_range(start: u64, end: u64) -> BlockRange { + BlockRange { + start: Some(BlockId { + height: start, + hash: Vec::new(), + }), + end: Some(BlockId { + height: end, + hash: Vec::new(), + }), + pool_types: Vec::new(), + } +} + +fn endpoint(url: &str) -> Result { + Endpoint::from_shared(url.to_string()).map_err(|source| connect_error(url, source)) +} + +fn tls(endpoint: &Endpoint, url: &str) -> Result { + endpoint + .clone() + .tls_config(tonic::transport::ClientTlsConfig::new().with_native_roots()) + .map_err(|source| connect_error(url, source)) +} + +fn connect_error(url: &str, source: impl std::error::Error + Send + Sync + 'static) -> Error { + Error::Connect { + url: url.to_string(), + source: Box::new(source), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn copy_hash_accepts_only_32_bytes() { + assert_eq!(copy_hash(&[7u8; 32]), Some([7u8; 32])); + assert_eq!(copy_hash(&[7u8; 31]), None); + assert_eq!(copy_hash(&[7u8; 33]), None); + assert_eq!(copy_hash(&[]), None); + } + + #[test] + fn block_range_spans_the_requested_heights_unfiltered() { + let range = block_range(100, 200); + assert_eq!(range.start.map(|id| id.height), Some(100)); + assert_eq!(range.end.map(|id| id.height), Some(200)); + // An unfiltered request is what real clients send; filtering here would + // measure a cheaper path than the one under test. + assert!(range.pool_types.is_empty()); + } +} diff --git a/packages/zaino-bench/src/main.rs b/packages/zaino-bench/src/main.rs new file mode 100644 index 00000000..31ba5b55 --- /dev/null +++ b/packages/zaino-bench/src/main.rs @@ -0,0 +1,78 @@ +//! Benchmark harness for Zaino. +//! +//! Answers three operational questions against a running zainod, from the +//! outside, over the interfaces a real client uses: +//! +//! - `sync` — how long does an initial sync take? +//! - `concurrent` — how many concurrent connections can it support? +//! - `serve` — how fast can it serve blocks on one stream? +//! +//! The measured configuration belongs with the numbers: see `docs/perf.md` for +//! results and `docs/example_configs/zainod-bench-mainnet.toml` for the node +//! config they were produced with. + +#![forbid(unsafe_code)] + +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +mod chain; +mod concurrent; +mod error; +mod grpc_client; +mod metrics; +mod serve; +mod stats; +mod sync; + +#[derive(Parser)] +#[command(name = "zaino-bench", about, version)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Measure how long zainod takes to sync to the chain tip. + Sync(sync::SyncArgs), + /// Load-test a running server with concurrent block-range clients. + Concurrent(concurrent::ConcurrentArgs), + /// Measure single-stream block serve rate, verifying the chain as it goes. + Serve(serve::ServeArgs), +} + +#[tokio::main] +async fn main() -> ExitCode { + // Must run before any rustls config is built — the workspace enables + // reqwest's `rustls-no-provider`, which never auto-selects one, so the + // metrics scrape panics with "No provider set" without this (ADR-0006). + zaino_common::crypto::ensure_default_crypto_provider(); + + let result = match Cli::parse().command { + Command::Sync(args) => sync::run(args).await, + Command::Concurrent(args) => concurrent::run(args).await, + Command::Serve(args) => serve::run(args).await, + }; + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!(); + eprintln!("Error: {error}"); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn the_cli_is_well_formed() { + Cli::command().debug_assert(); + } +} diff --git a/packages/zaino-bench/src/metrics.rs b/packages/zaino-bench/src/metrics.rs new file mode 100644 index 00000000..3ae44c4b --- /dev/null +++ b/packages/zaino-bench/src/metrics.rs @@ -0,0 +1,244 @@ +//! Scraping and parsing zainod's Prometheus endpoint. +//! +//! Measuring initial sync needs no new instrumentation: zainod already emits +//! everything required behind its `prometheus` feature. The harness reads that +//! endpoint from the outside, so a sync measurement never perturbs the run it is +//! measuring. + +use std::collections::HashMap; +use std::time::Duration; + +use crate::error::BenchError; + +/// The metrics this harness reads, in the form `zaino-state` registers them. +/// +/// Mirrors `zaino_state::metric_names`, which is `#[cfg(feature = "prometheus")]` +/// and would pull that feature into every workspace build if depended on +/// directly. The `metric_names_match_zaino_state` test below pins the two +/// together via a dev-dependency, so drift fails the build rather than the run. +pub(crate) mod names { + pub(crate) const SYNC_FINALIZED_HEIGHT: &str = "zaino.sync.finalized_height"; + pub(crate) const SYNC_TARGET_HEIGHT: &str = "zaino.sync.target_height"; + pub(crate) const SYNC_LAG_BLOCKS: &str = "zaino.sync.lag_blocks"; + pub(crate) const SYNC_HAS_REACHED_TIP: &str = "zaino.sync.has_reached_tip"; + pub(crate) const SYNC_TRANSACTIONS_TOTAL: &str = "zaino.sync.transactions_total"; + pub(crate) const DB_TIP_HEIGHT: &str = "zaino.db.tip_height"; + pub(crate) const CHAIN_TIP_HEIGHT: &str = "zaino.chain.tip_height"; +} + +/// One scrape of the endpoint, parsed into unlabelled sample values. +#[derive(Debug, Default)] +pub(crate) struct Scrape { + samples: HashMap, +} + +impl Scrape { + /// Reads a metric, or reports which one was missing. + pub(crate) fn require(&self, name: &'static str) -> Result { + self.get(name).ok_or(BenchError::MissingMetric(name)) + } + + /// Reads a metric that may legitimately be absent — a gauge zainod has not + /// set yet, such as `has_reached_tip` before the first sync iteration. + pub(crate) fn get(&self, name: &str) -> Option { + self.samples.get(&exposed_name(name)).copied() + } + + /// Reads a height metric, rounded to the integer it represents. + /// + /// Heights cross the exposition format as `f64`; every value in play is far + /// below 2^53, so the round-trip is exact. + pub(crate) fn height(&self, name: &'static str) -> Result { + Ok(self.require(name)?.max(0.0) as u64) + } + + /// Parses the Prometheus text exposition format. + /// + /// Only unlabelled samples are kept: every metric the harness reads is a + /// process-wide gauge, and skipping labelled series avoids collapsing a + /// per-method histogram into a single meaningless number. + fn parse(body: &str) -> Self { + let samples = body + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .filter_map(parse_sample) + .collect(); + + Self { samples } + } +} + +/// Polls `url` until it serves `required`, then returns that scrape. +/// +/// Waiting rather than failing is deliberate: the operator starts the harness +/// *before* zainod so that t0 is the moment zainod begins, not the moment +/// someone got to a second terminal. +/// +/// Readiness is the *metric*, not the endpoint. zainod binds its exporter +/// during startup, but the sync gauges are first set from inside the write +/// loop — after network adoption and the db open, and then only on a ten-second +/// throttle. So there is a window, unbounded on a cold start, where the endpoint +/// answers 200 with a body that does not mention `zaino.sync.*` yet. Returning +/// the first successful scrape lands in that window and fails the run before it +/// begins; waiting for the gauge is what "zainod has started syncing" actually +/// means. +pub(crate) async fn await_metric( + client: &reqwest::Client, + url: &str, + required: &'static str, + poll_interval: Duration, +) -> Scrape { + loop { + match scrape(client, url).await { + Ok(scrape) if scrape.get(required).is_some() => return scrape, + Ok(_) => { + eprintln!(" {url} is up; waiting for `{required}` (zainod is still starting)"); + tokio::time::sleep(poll_interval).await; + } + Err(error) => { + eprintln!(" waiting for {url}: {error}"); + tokio::time::sleep(poll_interval).await; + } + } + } +} + +/// Fetches and parses one scrape. +pub(crate) async fn scrape(client: &reqwest::Client, url: &str) -> Result { + let scrape_error = |source| BenchError::Scrape { + url: url.to_string(), + source, + }; + + let body = client + .get(url) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(scrape_error)? + .text() + .await + .map_err(scrape_error)?; + + Ok(Scrape::parse(&body)) +} + +/// Translates a registered metric name into the name it is exposed under. +/// +/// `metrics-exporter-prometheus` sanitises names to the Prometheus character +/// set, so the dotted name `zaino-state` registers reaches the wire as +/// `zaino_sync_finalized_height`. +fn exposed_name(registered: &str) -> String { + registered.replace(['.', '-'], "_") +} + +/// Splits `name value` / `name{labels} value`, keeping only unlabelled samples. +fn parse_sample(line: &str) -> Option<(String, f64)> { + let (name, value) = line.rsplit_once(char::is_whitespace)?; + let name = name.trim(); + if name.contains('{') { + return None; + } + Some((name.to_string(), value.trim().parse().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +# HELP zaino_sync_finalized_height Highest finalised block written to Zaino's db. +# TYPE zaino_sync_finalized_height gauge +zaino_sync_finalized_height 3200000 +# TYPE zaino_sync_target_height gauge +zaino_sync_target_height 3390744 +zaino_sync_has_reached_tip 0 +zaino_db_tip_height 3200000 +zaino_chain_tip_height 3390744 +# TYPE zaino_grpc_request_duration_seconds histogram +zaino_grpc_request_duration_seconds{method=\"get_block_range\",quantile=\"0.5\"} 0.012 + +"; + + #[test] + fn parses_unlabelled_gauges() { + let scrape = Scrape::parse(SAMPLE); + assert_eq!( + scrape.height(names::SYNC_FINALIZED_HEIGHT).ok(), + Some(3200000) + ); + assert_eq!(scrape.height(names::SYNC_TARGET_HEIGHT).ok(), Some(3390744)); + assert_eq!(scrape.get(names::SYNC_HAS_REACHED_TIP), Some(0.0)); + } + + #[test] + fn skips_labelled_series() { + let scrape = Scrape::parse(SAMPLE); + assert_eq!(scrape.get("zaino.grpc.request_duration_seconds"), None); + } + + #[test] + fn a_missing_metric_names_itself() { + let error = Scrape::default() + .require(names::SYNC_FINALIZED_HEIGHT) + .expect_err("empty scrape has no metrics"); + assert!( + error.to_string().contains(names::SYNC_FINALIZED_HEIGHT), + "error should name the missing metric: {error}" + ); + } + + /// The scrape zainod serves between binding its exporter and the write + /// loop's first gauge set: a healthy 200 that does not carry `zaino.sync.*` + /// yet. `await_metric` must keep waiting on this, not accept it — accepting + /// it is what failed a run before it started. + #[test] + fn a_started_exporter_without_the_sync_gauges_is_not_ready() { + const STARTING_UP: &str = "\ +# TYPE zaino_grpc_request_duration_seconds histogram +zaino_grpc_request_duration_seconds{method=\"get_block_range\",quantile=\"0.5\"} 0.012 +"; + let scrape = Scrape::parse(STARTING_UP); + assert!( + scrape.get(names::SYNC_FINALIZED_HEIGHT).is_none(), + "the readiness predicate must treat this scrape as not-yet-ready" + ); + assert!( + Scrape::parse(SAMPLE) + .get(names::SYNC_FINALIZED_HEIGHT) + .is_some(), + "and must treat a scrape carrying the gauge as ready" + ); + } + + #[test] + fn dotted_names_reach_the_wire_underscored() { + assert_eq!( + exposed_name(names::SYNC_FINALIZED_HEIGHT), + "zaino_sync_finalized_height" + ); + } + + /// Pins this crate's copy of the metric names to the ones `zaino-state` + /// actually emits, so a rename there fails the build rather than silently + /// producing a harness that waits forever for a metric nobody publishes. + #[test] + fn metric_names_match_zaino_state() { + use zaino_state::metric_names as upstream; + + assert_eq!( + names::SYNC_FINALIZED_HEIGHT, + upstream::SYNC_FINALIZED_HEIGHT + ); + assert_eq!(names::SYNC_TARGET_HEIGHT, upstream::SYNC_TARGET_HEIGHT); + assert_eq!(names::SYNC_LAG_BLOCKS, upstream::SYNC_LAG_BLOCKS); + assert_eq!(names::SYNC_HAS_REACHED_TIP, upstream::SYNC_HAS_REACHED_TIP); + assert_eq!( + names::SYNC_TRANSACTIONS_TOTAL, + upstream::SYNC_TRANSACTIONS_TOTAL + ); + assert_eq!(names::DB_TIP_HEIGHT, upstream::DB_TIP_HEIGHT); + assert_eq!(names::CHAIN_TIP_HEIGHT, upstream::CHAIN_TIP_HEIGHT); + } +} diff --git a/packages/zaino-bench/src/serve.rs b/packages/zaino-bench/src/serve.rs new file mode 100644 index 00000000..b19ef38c --- /dev/null +++ b/packages/zaino-bench/src/serve.rs @@ -0,0 +1,264 @@ +//! Single-stream serve rate — "how fast can you serve blocks?" +//! +//! Ported from the `zaino-admin check` tool on the `hahn/store` branch. Streams +//! one large `GetBlockRange` and verifies the chain links as blocks arrive, so +//! the run reports serve rate and correctness from the same pass: a fast answer +//! that does not link up is not an answer. + +use std::time::Instant; + +use clap::Args; +use futures::StreamExt; +use zaino_proto::proto::compact_formats::CompactBlock; + +use crate::chain::ChainVerifier; +use crate::error::BenchError; +use crate::grpc_client; + +/// Stream a height range from one connection and report blocks/s and bytes/s. +#[derive(Args)] +pub(super) struct ServeArgs { + /// Server under test (e.g. "http://127.0.0.1:8137"). + #[arg(short, long)] + server: String, + + /// Height to start streaming from. + #[arg(long, default_value = "0")] + start_height: u64, + + /// Height to stop at. Defaults to the server's chain tip. + #[arg(long)] + end_height: Option, + + /// Print progress every N blocks. + #[arg(long, default_value = "100000")] + progress_interval: u64, + + /// Stop after this many chain errors. + #[arg(long, default_value = "10")] + max_errors: usize, +} + +/// What the stream delivered. +struct Delivered { + blocks: u64, + bytes: u64, + verifier: ChainVerifier, + stream_error: Option, +} + +pub(super) async fn run(args: ServeArgs) -> Result<(), BenchError> { + let mut client = grpc_client::connect_eager(&args.server).await?; + let tip = grpc_client::get_latest_height(&mut client).await?; + let end_height = args.end_height.unwrap_or(tip); + + if args.start_height > end_height { + return Err(BenchError::Args(format!( + "--start-height {} is above --end-height {end_height}", + args.start_height + ))); + } + + eprintln!("Server: {}", args.server); + eprintln!("Chain tip: {tip}"); + eprintln!( + "Streaming: {}..={end_height} ({} blocks)", + args.start_height, + end_height - args.start_height + 1, + ); + eprintln!(); + + // The clock starts at the request, not at the connect: connect cost is the + // load test's concern, and folding it in here would understate serve rate on + // short ranges. + let started = Instant::now(); + let stream = + grpc_client::block_range_stream(&mut client, args.start_height, end_height).await?; + let delivered = drain(stream, args.progress_interval.max(1), args.max_errors).await; + let elapsed = started.elapsed(); + + report(&delivered, elapsed); + + let total_errors = delivered.verifier.total_errors(); + if total_errors > 0 { + return Err(BenchError::InvalidChain(total_errors)); + } + Ok(()) +} + +async fn drain( + mut stream: tonic::Streaming, + progress_interval: u64, + max_errors: usize, +) -> Delivered { + let mut delivered = Delivered { + blocks: 0, + bytes: 0, + verifier: ChainVerifier::new(), + stream_error: None, + }; + + while let Some(item) = stream.next().await { + let block = match item { + Ok(block) => block, + Err(status) => { + // A mid-stream failure is itself a result worth reporting — the + // `hahn/store` numbers show exactly this under load — so record + // it and summarise what did arrive rather than bailing out. + delivered.stream_error = Some(status.to_string()); + break; + } + }; + + delivered.blocks += 1; + delivered.bytes += wire_size(&block); + delivered.verifier.push(&block); + + if delivered.verifier.breaks().len() >= max_errors { + break; + } + if delivered.blocks.is_multiple_of(progress_interval) { + eprintln!(" ... {} blocks streamed ...", delivered.blocks); + } + } + + delivered +} + +/// Approximate served bytes for a compact block. +/// +/// `prost::Message::encoded_len` would need a `prost` dependency purely to +/// re-measure what the server already sent; summing the variable-length fields +/// tracks the payload closely enough for a throughput figure, and the report +/// labels it as approximate. +fn wire_size(block: &CompactBlock) -> u64 { + let header = (block.hash.len() + block.prev_hash.len() + block.header.len()) as u64; + let transactions: u64 = block + .vtx + .iter() + .map(|tx| { + let spends: usize = tx.spends.iter().map(|spend| spend.nf.len()).sum(); + let outputs: usize = tx + .outputs + .iter() + .map(|output| { + output.cmu.len() + output.ephemeral_key.len() + output.ciphertext.len() + }) + .sum(); + let actions: usize = tx + .actions + .iter() + .map(|action| { + action.nullifier.len() + + action.cmx.len() + + action.ephemeral_key.len() + + action.ciphertext.len() + }) + .sum(); + (tx.txid.len() + spends + outputs + actions) as u64 + }) + .sum(); + + header + transactions +} + +fn report(delivered: &Delivered, elapsed: std::time::Duration) { + let seconds = elapsed.as_secs_f64(); + + eprintln!(); + if let Some(error) = &delivered.stream_error { + eprintln!(" Stream error after {} blocks: {error}", delivered.blocks); + eprintln!(); + } + + eprintln!("══════════════════════════════════════════"); + eprintln!(" Single-Stream Serve Rate — Summary"); + eprintln!("══════════════════════════════════════════"); + eprintln!(" Blocks streamed: {}", delivered.blocks); + eprintln!(" Wall-clock time: {seconds:.2}s"); + if seconds > 0.0 { + eprintln!( + " Serve rate: {:.0} blocks/s", + delivered.blocks as f64 / seconds + ); + eprintln!( + " Payload rate: {:.1} MB/s (approx)", + delivered.bytes as f64 / seconds / 1_000_000.0 + ); + } + eprintln!( + " Payload: {:.1} MB (approx)", + delivered.bytes as f64 / 1_000_000.0 + ); + eprintln!( + " Chain breaks: {}", + delivered.verifier.breaks().len() + ); + eprintln!( + " Hash length errors: {}", + delivered.verifier.hash_length_errors() + ); + eprintln!( + " Total errors: {}", + delivered.verifier.total_errors() + ); + eprintln!(); + + for chain_break in delivered.verifier.breaks() { + eprintln!( + " CHAIN BREAK at height {}: {}", + chain_break.height, chain_break.detail + ); + } + + if delivered.verifier.total_errors() == 0 { + eprintln!( + " ✅ Chain is VALID — all {} blocks link correctly.", + delivered.blocks + ); + } else { + eprintln!( + " ❌ Chain is INVALID — {} error(s) found.", + delivered.verifier.total_errors() + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zaino_proto::proto::compact_formats::{CompactSaplingOutput, CompactTx}; + + #[test] + fn wire_size_counts_header_and_transaction_payload() { + let block = CompactBlock { + height: 1, + hash: vec![0u8; 32], + prev_hash: vec![0u8; 32], + header: vec![0u8; 100], + vtx: vec![CompactTx { + txid: vec![0u8; 32], + outputs: vec![CompactSaplingOutput { + cmu: vec![0u8; 32], + ephemeral_key: vec![0u8; 32], + ciphertext: vec![0u8; 52], + }], + ..CompactTx::default() + }], + ..CompactBlock::default() + }; + + // 32 + 32 + 100 header, then 32 + (32 + 32 + 52) for the one transaction. + assert_eq!(wire_size(&block), 164 + 148); + } + + #[test] + fn an_empty_block_has_only_its_header() { + let block = CompactBlock { + hash: vec![0u8; 32], + prev_hash: vec![0u8; 32], + ..CompactBlock::default() + }; + assert_eq!(wire_size(&block), 64); + } +} diff --git a/packages/zaino-bench/src/stats.rs b/packages/zaino-bench/src/stats.rs new file mode 100644 index 00000000..ba36fd97 --- /dev/null +++ b/packages/zaino-bench/src/stats.rs @@ -0,0 +1,94 @@ +//! Summary statistics over a sample of durations. + +/// Min / mean / max plus the tail percentiles. +/// +/// Under load the mean understates what a client actually experiences, so every +/// latency line the harness prints carries p50/p95/p99 alongside it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Summary { + pub(crate) min: f64, + pub(crate) mean: f64, + pub(crate) max: f64, + pub(crate) p50: f64, + pub(crate) p95: f64, + pub(crate) p99: f64, +} + +impl Summary { + /// Summarises `samples`, or returns `None` when there is nothing to + /// summarise (every connection failed, so there are no timings to report). + pub(crate) fn new(samples: &[f64]) -> Option { + if samples.is_empty() { + return None; + } + + let mut sorted = samples.to_vec(); + sorted.sort_by(f64::total_cmp); + + Some(Self { + min: sorted[0], + mean: sorted.iter().sum::() / sorted.len() as f64, + max: sorted[sorted.len() - 1], + p50: percentile(&sorted, 0.50), + p95: percentile(&sorted, 0.95), + p99: percentile(&sorted, 0.99), + }) + } + + /// One aligned line: `