diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml index 567b3e259c..e055cf1fff 100644 --- a/.github/actions/prepare/action.yml +++ b/.github/actions/prepare/action.yml @@ -28,7 +28,7 @@ inputs: outputs: feature-flags: description: 'Feature flags' - value: ${{ steps.prepare.outputs.flags }} + value: ${{ steps.prepare.outputs.flags || steps.prepare-all.outputs.flags }} runs: using: 'composite' steps: diff --git a/.github/helpers/check-dep-graph.py b/.github/helpers/check-dep-graph.py new file mode 100644 index 0000000000..d3aec508a6 --- /dev/null +++ b/.github/helpers/check-dep-graph.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 + +import os +import subprocess +import sys + +CRATES_IN_GRAPH = set([ + # ./components + 'eip681', + 'equihash', + 'f4jumble', + 'zcash_address', + 'zcash_encoding', + 'zcash_protocol', + 'zip321', + # ./ + 'pczt', + 'zcash_client_backend', + 'zcash_client_memory', + 'zcash_client_sqlite', + 'zcash_extensions', + 'zcash_history', + 'zcash_keys', + 'zcash_primitives', + 'zcash_proofs', + 'zcash_transparent', + # Other repos + 'orchard', + 'sapling-crypto', + 'zcash_note_encryption', + 'zcash_spec', + 'zip32', +]) + +def main(): + script_dir = os.path.dirname(os.path.realpath(__file__)) + base_dir = os.path.dirname(os.path.dirname(script_dir)) + readme = os.path.join(base_dir, 'README.md') + + # Extract the dependency graph edges from the readme. + readme_edges = [] + with open(readme, 'r', encoding='utf8') as f: + line = '' + while not 'START mermaid-dependency-graph' in line: + line = f.readline() + line = f.readline() + while not 'END mermaid-dependency-graph' in line: + if '-->' in line: + # Include commented-out edges for linting purposes. + line = line.strip() + if line.startswith('%% '): + line = line[3:] + (crate, dependency) = line.strip().split(' --> ', 1) + if crate in CRATES_IN_GRAPH and dependency in CRATES_IN_GRAPH: + readme_edges.append((crate, dependency)) + line = f.readline() + + # Check for duplicate edges. + readme_edges_set = set(readme_edges) + has_duplicate_edges = len(readme_edges) != len(readme_edges_set) + if has_duplicate_edges: + duplicate_edges = readme_edges + for edge in readme_edges_set: + duplicate_edges.remove(edge) + duplicate_edges = ['%s --> %s' % edge for edge in duplicate_edges] + print('WARNING: Duplicate edges in README.md dependency graph:') + for edge in sorted(duplicate_edges): + print(' %s --> %s' % edge) + + # Extract the dependency graph edges from the Rust workspace. + cargo_graph = subprocess.run( + ['cargo', 'tree', '--all-features', '-e', 'normal', '--prefix', 'depth', '-f', ' {p}'], + stdout=subprocess.PIPE, + universal_newlines=True) + cargo_edges = set() + crate_stack = [] + for line in cargo_graph.stdout.splitlines(): + if len(line.strip()) == 0: + continue + (depth, crate, _) = line.strip().split(' ', 2) + depth = int(depth) + + if depth == 0: + crate_stack = [crate] + continue + + while len(crate_stack) > depth: + crate_stack.pop() + if crate_stack[-1] in CRATES_IN_GRAPH and crate in CRATES_IN_GRAPH: + cargo_edges.add((crate_stack[-1], crate)) + crate_stack.append(crate) + + # Check for missing edges. + missing_edges = cargo_edges.difference(readme_edges_set) + has_missing_edges = len(missing_edges) > 0 + if has_missing_edges: + print('ERROR: Missing edges from README.md dependency graph:') + for edge in sorted(missing_edges): + print(' %s --> %s' % edge) + + # Check for stale edges. + stale_edges = readme_edges_set.difference(cargo_edges) + has_stale_edges = len(stale_edges) > 0 + if has_stale_edges: + print('ERROR: Stale edges in README.md dependency graph:') + for edge in sorted(stale_edges): + print(' %s --> %s' % edge) + + if has_duplicate_edges or has_missing_edges or has_stale_edges: + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/.github/workflows/audits.yml b/.github/workflows/audits.yml index 3c72aeff5d..cb4543fc39 100644 --- a/.github/workflows/audits.yml +++ b/.github/workflows/audits.yml @@ -13,7 +13,7 @@ jobs: name: Vet Rust dependencies runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: dtolnay/rust-toolchain@stable @@ -28,10 +28,10 @@ jobs: name: Check licenses runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: EmbarkStudios/cargo-deny-action@f2ba7abc2abebaf185c833c3961145a3c275caad # v2.0.13 + - uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2.0.15 with: command: check licenses diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml index 1750e07914..7ea0f73cf5 100644 --- a/.github/workflows/book.yml +++ b/.github/workflows/book.yml @@ -5,15 +5,24 @@ on: branches: - main +permissions: + pages: write + id-token: write + jobs: deploy: runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - id: prepare uses: ./.github/actions/prepare + with: + all-features: true - uses: dtolnay/rust-toolchain@nightly id: toolchain - run: rustup override set "${TOOLCHAIN}" @@ -34,8 +43,11 @@ jobs: mkdir -p ./book/book/rustdoc mv ./target/doc ./book/book/rustdoc/latest - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./book/book + path: ./book/book + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c2524c30a..ffa92645c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: - Linux state: - transparent + - transparent-inputs-only + - transparent-key-encoding-only - Sapling-only - Orchard - all-pools @@ -29,10 +31,14 @@ jobs: include: - target: Linux - os: ubuntu-latest-8cores + os: ${{ vars.UBUNTU_LARGE_RUNNER || 'ubuntu-latest' }} - state: transparent transparent-pool: true + - state: transparent-inputs-only + extra_flags: transparent-inputs + - state: transparent-key-encoding-only + extra_flags: transparent-key-encoding - state: Orchard orchard-pool: true - state: all-pools @@ -48,7 +54,7 @@ jobs: RUSTDOCFLAGS: ${{ matrix.rustflags }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - id: prepare @@ -59,15 +65,10 @@ jobs: all-pools: ${{ matrix.all-pools || false }} all-features: ${{ matrix.all-features || false }} extra-features: ${{ matrix.extra_flags || '' }} - - uses: actions/cache@v4 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-msrv-${{ hashFiles('**/Cargo.lock') }} + shared-key: msrv + save-if: ${{ matrix.state == 'all-features' }} - name: Run tests run: > cargo test @@ -97,7 +98,7 @@ jobs: - target: macOS os: macOS-latest - target: Windows - os: windows-latest-8cores + os: ${{ vars.WINDOWS_LARGE_RUNNER || 'windows-latest' }} - state: transparent transparent-pool: true @@ -122,7 +123,7 @@ jobs: RUSTDOCFLAGS: ${{ matrix.rustflags }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - id: prepare @@ -133,15 +134,10 @@ jobs: all-pools: ${{ matrix.all-pools || false }} all-features: ${{ matrix.all-features || false }} extra-features: ${{ matrix.extra_flags || '' }} - - uses: actions/cache@v4 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-msrv-${{ hashFiles('**/Cargo.lock') }} + shared-key: msrv + save-if: ${{ matrix.state == 'all-features' }} - name: Run tests run: > cargo test @@ -167,7 +163,7 @@ jobs: include: - target: Linux - os: ubuntu-latest-8cores + os: ${{ vars.UBUNTU_LARGE_RUNNER || 'ubuntu-latest' }} - state: transparent transparent-pool: true @@ -184,7 +180,7 @@ jobs: RUSTDOCFLAGS: ${{ matrix.rustflags }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - id: prepare @@ -195,15 +191,10 @@ jobs: all-pools: false all-features: ${{ matrix.all-features || false }} extra-features: ${{ matrix.extra_flags || '' }} - - uses: actions/cache@v4 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-msrv-${{ hashFiles('**/Cargo.lock') }} + shared-key: msrv + save-if: "false" - name: Run slow tests run: > cargo test @@ -246,7 +237,7 @@ jobs: RUSTDOCFLAGS: ${{ matrix.rustflags }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - id: prepare @@ -254,15 +245,10 @@ jobs: with: all-features: ${{ matrix.all-features || false }} extra-features: ${{ matrix.extra_flags || '' }} - - uses: actions/cache@v4 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-msrv-${{ hashFiles('**/Cargo.lock') }} + shared-key: msrv + save-if: "false" - name: Run check run: > cargo check @@ -280,20 +266,14 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - id: prepare uses: ./.github/actions/prepare - - uses: actions/cache@v4 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-latest + shared-key: latest - uses: dtolnay/rust-toolchain@stable id: toolchain - run: rustup override set "${TOOLCHAIN}" @@ -324,7 +304,7 @@ jobs: build_deps: > gcc-multilib steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false path: crates @@ -370,7 +350,7 @@ jobs: build_deps: > gcc-arm-none-eabi steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false path: crates @@ -417,7 +397,7 @@ jobs: name: Bitrot check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # Build benchmarks to prevent bitrot @@ -430,7 +410,7 @@ jobs: permissions: checks: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # We can't currently use only the common feature set (to exclude checking of @@ -454,7 +434,7 @@ jobs: checks: write continue-on-error: true steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false # We can't currently use only the common feature set (to exclude checking of @@ -479,48 +459,13 @@ jobs: args: > -W clippy::all - codecov: - name: Code coverage - runs-on: ubuntu-latest - container: - image: xd009642/tarpaulin:develop-nightly - options: --security-opt seccomp=unconfined - - steps: - - uses: actions/checkout@v5 - with: - persist-credentials: false - - id: prepare - uses: ./.github/actions/prepare - - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: codecov-cargo-${{ hashFiles('**/Cargo.lock') }} - - name: Generate coverage report - run: > - cargo tarpaulin - --engine llvm - ${{ steps.prepare.outputs.feature-flags }} - --release - --timeout 600 - --out xml - - name: Upload coverage to Codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - # This is run with nightly and `RUSTDOCFLAGS` to emulate the documentation renders on # docs.rs and in `.github/workflows/book.yml` as closely as possible. doc-links: name: Intra-doc links runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - id: prepare @@ -546,17 +491,27 @@ jobs: name: Rustfmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Check formatting run: cargo fmt --all -- --check + readme-graph: + name: Readme dependency graph + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Check consistency + run: python3 .github/helpers/check-dep-graph.py + protobuf: name: protobuf consistency runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - id: prepare @@ -582,7 +537,7 @@ jobs: name: UUID validity runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Extract UUIDs diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml new file mode 100644 index 0000000000..4c2753a7e8 --- /dev/null +++ b/.github/workflows/mutants.yml @@ -0,0 +1,109 @@ +name: Mutation testing + +env: + CARGO_TERM_COLOR: always + +on: + # Run weekly on Sundays at 2 AM UTC + schedule: + - cron: "0 2 * * 0" + # Allow manual triggering + workflow_dispatch: + inputs: + package: + description: "Package to test (select 'all' for all packages)" + required: false + default: "all" + type: choice + options: + - all + - eip681 + - equihash + - f4jumble + - pczt + - zcash_address + - zcash_client_backend + - zcash_client_sqlite + - zcash_encoding + - zcash_extensions + - zcash_history + - zcash_keys + - zcash_primitives + - zcash_proofs + - zcash_protocol + - zcash_transparent + - zip321 + +permissions: + contents: read + +# Only run one mutation test at a time +concurrency: + group: mutation-testing + cancel-in-progress: false + +jobs: + mutants: + name: Mutation testing of ${{ matrix.package }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: + - eip681 + - equihash + - f4jumble + - pczt + - zcash_address + - zcash_client_backend + - zcash_client_sqlite + - zcash_encoding + - zcash_extensions + - zcash_history + - zcash_keys + - zcash_primitives + - zcash_proofs + - zcash_protocol + - zcash_transparent + - zip321 + # Only filter if a specific package was requested via workflow_dispatch + # For push/pull_request events, inputs.package is undefined, so default to 'all' + exclude: + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'eip681', 'eip681', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'equihash', 'equihash', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'f4jumble', 'f4jumble', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'pczt', 'pczt', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_address', 'zcash_address', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_client_backend', 'zcash_client_backend', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_client_sqlite', 'zcash_client_sqlite', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_encoding', 'zcash_encoding', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_extensions', 'zcash_extensions', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_history', 'zcash_history', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_keys', 'zcash_keys', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_primitives', 'zcash_primitives', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_proofs', 'zcash_proofs', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_protocol', 'zcash_protocol', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zcash_transparent', 'zcash_transparent', 'NONE') }} + - package: ${{ case((github.event.inputs.package || 'all') != 'all' && (github.event.inputs.package || 'all') != 'zip321', 'zip321', 'NONE') }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + id: toolchain + - run: rustup override set "${TOOLCHAIN}" + shell: sh + env: + TOOLCHAIN: ${{steps.toolchain.outputs.name}} + - uses: taiki-e/install-action@e9e8e031bcd90cdbe8ac6bb1d376f8596e587fbf # v2.70.2 + with: + tool: cargo-mutants + - run: cargo mutants --package "${{ matrix.package }}" --all-features -vV --in-place + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: always() + with: + name: mutants-${{ matrix.package }} + path: | + mutants.out/ + retention-days: 30 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index ac7ada0b9c..9a54d4c923 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -4,28 +4,21 @@ on: push: branches: ["main"] pull_request: - branches: ["*"] + branches: ["**"] + +permissions: {} jobs: zizmor: - name: zizmor latest via Cargo + name: Run zizmor ๐ŸŒˆ runs-on: ubuntu-latest permissions: - contents: read - security-events: write + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. steps: - name: Checkout repository - uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0 + a doc-only README update + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Install the latest version of uv - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 + - name: Run zizmor ๐ŸŒˆ - run: uvx zizmor --format sarif . > results.sarif - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 - with: - sarif_file: results.sarif - category: zizmor + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..f26937c7bd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,350 @@ +# Zcash Rust crates - Agent Guidelines + +> This file is read by AI coding agents (Claude Code, GitHub Copilot, Cursor, Devin, etc.). +> It provides project context and contribution policies. +> +> For the full contribution guide, see [CONTRIBUTING.md](CONTRIBUTING.md). + +This is a Rust workspace of cryptographic library crates for the Zcash protocol. +Our priorities are **security, performance, and convenience** โ€” in that order. +Rigor is highly valued throughout the codebase. + +Many people depend on these libraries and we prefer to "do it right" the first time, +then "make it fast". + +## MUST READ FIRST โ€” CONTRIBUTION GATE (DO NOT SKIP) + +**STOP. Do not open or draft a PR until this gate is satisfied.** + +For any contribution that might become a PR, the agent must ask the user these checks +first: + +- "PR COMPLIANCE CHECK: Have you discussed this change with the librustzcash team in a + GitHub issue?" +- "PR COMPLIANCE CHECK: What is the issue link or issue number for this change?" +- "PR COMPLIANCE CHECK: Has a librustzcash team member responded to that issue + acknowledging the proposed work?" + +This PR compliance check must be the agent's first reply in contribution-focused sessions. + +**An issue existing is not enough.** The issue must have a response or acknowledgment from +a team member (a maintainer). An issue with no team response does not satisfy this gate. +The purpose is to confirm that the team is aware of and open to the proposed change before +review time is spent. + +If the user cannot provide prior discussion with team acknowledgment: + +- Do not open a PR. +- Offer to help create or refine the issue first. +- Remind the user to wait for a team member to respond before starting work. +- If the user still wants code changes, keep work local and explicitly remind them the PR + will likely be closed without prior team discussion. + +This gate is mandatory for all agents, **unless the user is a repository maintainer** as +described in the next subsection. + +### Maintainer Bypass + +If `gh` CLI is authenticated, the agent can check maintainer status: + +```bash +gh api repos/zcash/librustzcash --jq '.permissions | .admin or .maintain or .push' +``` + +If this returns `true`, the user has write access (or higher) and the contribution gate +can be skipped. Team members with write access manage their own priorities and don't need +to gate on issue discussion for their own work. + +### Contribution Policy + +Before contributing please see the [CONTRIBUTING.md] file. + +- All PRs require human review from a maintainer. This incurs a cost upon the dev team, + so ensure your changes are not frivolous. +- Keep changes focused โ€” avoid unsolicited refactors or broad "improvement" PRs. +- See also the license and contribution terms in `README.md`. + +### AI Disclosure + +If AI tools were used in the preparation of a commit, the contributor MUST include +`Co-Authored-By:` metadata in the commit message identifying the AI system. Failure to +include this is grounds for closing the pull request. The contributor is the sole +responsible author โ€” "the AI generated it" is not a justification during review. + +Example: +``` +Co-Authored-By: Claude +``` + +## Crate Architecture + +See `README.md` for the full dependency graph (Mermaid diagram). Below is a text summary +since LLMs cannot render diagrams. + +### Zcash Protocol + +| Crate | Role | +| --- | --- | +| `zcash_protocol` | Constants, consensus parameters, bounded value types (`Zatoshis`, `ZatBalance`), memo types | +| `zcash_transparent` | Bitcoin-derived transparent addresses, inputs, outputs, bundles; transparent PCZT support | +| `zcash_primitives` | Primary transaction data type, transaction builder(s), proving/signing/serialization, low-level fee types | +| `zcash_proofs` | Sprout circuit and Sapling proving system | + +### Keys, Addresses & Wallet Support + +| Crate | Role | +| --- | --- | +| `zcash_address` | Parsing & serialization of Zcash addresses (unified address/fvk/ivk containers); no protocol-specific deps | +| `zip321` | Parsing & serialization for ZIP 321 payment requests | +| `eip681` | Parsing & serialization for EIP 681 payment requests | +| `zcash_keys` | Spending keys, viewing keys, addresses; ZIP 32 key derivation; Unified spending/viewing keys | +| `pczt` | Partially Constructed Zcash Transaction types and role interfaces | +| `zcash_client_backend` | Wallet framework: storage APIs, chain scanning, light client protocol, fee calculation, transaction proposals | +| `zcash_client_sqlite` | SQLite-based implementation of `zcash_client_backend` storage APIs | +| `zcash_client_memory` | In-memory implementation of `zcash_client_backend` storage APIs | + +### Utilities & Standalone Components + +| Crate | Role | +| --- | --- | +| `f4jumble` | Encoding for Unified addresses | +| `zcash_encoding` | Bitcoin-derived transaction encoding utilities | +| `equihash` | Proof-of-work protocol implementation | + +### External Protocol Crates (separate repos, depended upon here) + +| Crate | Role | +| --- | --- | +| `sapling-crypto` | Sapling shielded protocol | +| `orchard` | Orchard shielded protocol | +| `zcash_note_encryption` | Note encryption shared across shielded protocols | +| `zip32` | HD key derivation (ZIP 32) | +| `zcash_spec` | Zcash specification utilities | + +### Dependency Rules + +- Dependencies flow **downward**: higher-level crates depend on lower-level ones, never + the reverse. +- `zcash_protocol` is the lowest layer in this repo โ€” most crates depend on it. +- `zcash_client_sqlite` sits at the top, depending on `zcash_client_backend`. + +## Build & Test Commands + +For the most part we follow standard Rust `cargo` practices. + +**Important:** This workspace requires `--all-features` for builds and tests to match CI. +Any change should not cause any regression in building or testing using +`--no-default-features` or other combinations of features that may interact +with the changed code (such as `orchard` and/or `transparent-inputs`). + +```sh +# Check without codegen (fastest iteration) +cargo check --workspace --all-features + +# Build entire workspace +cargo build --workspace --all-features + +# Test entire workspace (matches CI) +cargo test --workspace --all-features + +# Test with feature combinations relevant to the changed code, e.g. +cargo test --workspace --features orchard +cargo test --workspace --features transparent-inputs +``` + +### Test Performance + +Tests are computationally expensive (cryptographic proofs). The `[profile.test]` uses +`opt-level = 3` by default. This means compilation is slow but test execution is fast. + +Using `--profile=dev` trades this: compilation is fast but tests run ~10x slower because +cryptographic operations are unoptimized. Only use it when iterating on compilation errors, +not when you need to actually run tests to completion. + +```sh +# Fast compile, slow run โ€” only for checking compilation +cargo test --profile=dev -p + +# Expensive/slow tests (CI runs these separately) +cargo test --workspace --all-features --features expensive-tests + +# NU7 unstable network upgrade tests +RUSTFLAGS='--cfg zcash_unstable="nu7"' cargo test --workspace --all-features +``` + +## Lint & Format + +```sh +# Format (CI runs this with --check) +cargo fmt --all +cargo fmt --all -- --check + +# Clippy โ€” must pass with zero warnings (CI uses -D warnings) +cargo clippy --all-features --all-targets -- -D warnings + +# Doc link validation (CI uses nightly) +cargo doc --no-deps --workspace --all-features --document-private-items +``` + +## Feature Flags + +### Common Workspace Feature Flags + +These feature flags are used consistently across crates in the repository: + +- `test-dependencies` โ€” exposes proptest strategies and mock types for downstream testing +- `transparent-inputs` โ€” transparent transaction input support +- `orchard` โ€” Orchard shielded protocol support +- `sapling` โ€” Sapling shielded protocol support +- `unstable` โ€” unstable or in-development functionality +- `multicore` โ€” multi-threaded proving +- `std` โ€” standard library support (most crates are `no_std` by default) + +### Key Crate-Specific Feature Flags + +- `transparent-key-import`, `transparent-key-encoding` +- `bundled-prover`, `download-params` โ€” Sapling proving +- `lightwalletd-tonic`, `sync` โ€” light client gRPC and sync +- `unstable-serialization`, `unstable-spanning-tree` +- `expensive-tests` โ€” computationally expensive test suite + +### Unstable Cfg Flags + +These are `cfg` flags (not Cargo feature flags) that enable unstable or +in-development functionality: + +- `zcash_unstable="zfuture"` +- `zcash_unstable="nu7"` + +## Code Style + +Standard Rust naming conventions are enforced by `clippy` and `rustfmt`. The following +are project-specific conventions beyond those defaults. + +### Imports + +Group imports in three blocks separated by blank lines: + +1. `core` / `alloc` / `std` (standard library) +2. External crates (alphabetical) +3. `crate::` and `self::` (internal) + +Feature-gated imports go at the end, separately. Consolidate multi-item imports with +nested `use` syntax: `use zcash_protocol::{PoolType, consensus::BlockHeight};` + +### Error Handling + +- Always use `Result` with custom error `enum`s. + * In the past we've used `thiserror`, but have had good results with `snafu` and prefer to + continue with `snafu`. +- Provide `From` implementations for error conversion between layers. +- Generic error type parameters are used in wallet crates to stay storage-agnostic. + +### Type Safety + +Type safety is paramount. This is a security-critical codebase. + +- Struct fields must be private (or `pub(crate)`). Expose constructors returning + `Result` or `Option` that enforce invariants, plus accessor methods. +- Make invalid states unrepresentable. +- Error enum types (and ONLY error enum types) should be non-exhaustive. +- Use newtypes over bare integers, strings, and byte arrays. Avoid `usize` except for + Rust collection indexing. +- Use `enum`s liberally. Prefer custom `enum` variants with semantic meaning over + boolean arguments/return values. +- Prefer immutability. Only use `mut` when strictly needed for performance. +- When structured enum variants are needed, wrap an immutable type rather than using + inline fields, to ensure safe construction. This can be relaxed for error enum types. + +### Naming โ€” Project-Specific Conventions + +- Feature flags: `kebab-case` โ€” `transparent-inputs`, `test-dependencies` +- Acronyms as words in types: `TxId`, `Pczt`, `Ufvk` (not `TXID`, `PCZT`) +- Proptest generators: `arb_` prefix โ€” `arb_bundle`, `arb_transparent_addr` + * If possible use `proptest` for rigorous testing, especially when parsing + +### Visibility + +- `pub` items MUST be intentionally part of the public API. No public types in private + modules (exception: sealed trait pattern). +- Use `pub(crate)` for internal sharing between modules. +- Test-only constructors/utilities go behind `#[cfg(any(test, feature = "test-dependencies"))]`. + +### Documentation + +- All public API items MUST have complete `rustdoc` doc comments (`///`). + * Document all error cases +- Crate-level docs use `//!` at the top of `lib.rs`. +- Reference ZIP/BIP specs with markdown links: `/// [ZIP 316]: https://zips.z.cash/zip-0316` +- Use backtick links for cross-references to other items. +- All crates must have `#![deny(rustdoc::broken_intra_doc_links)]`. + +### Serialization + +- All serialized data MUST be versioned at the top level. +- Do NOT use derived `serde` serialization except in explicitly marked cases (e.g. `pczt`). +- Serialization-critical types must not be modified after public release. + +### Side Effects & Capability-Oriented Programming + +- Write referentially transparent functions where possible. +- Use `for` loops (not `map`/iterator chains) when the body produces side effects. +- Provide side-effect capabilities as explicit trait arguments (e.g., `clock: impl Clock`). +- Define capability traits in terms of domain types, not implementation details. + +## Architecture Patterns + +- **Authorization typestate**: Bundles are parameterized by `Authorization` associated + types to enforce compile-time correctness of transaction construction stages. +- **`no_std` by default**: Most crates use `#![no_std]` with `extern crate alloc`. + The `std` feature is optional. +- **`MapAuth` trait**: Transforms authorization types during transaction construction. +- **`from_parts` constructors**: Preferred over public struct fields. +- **`testing` submodules**: Exposed via `test-dependencies` feature for cross-crate + test utilities (proptest strategies, mock implementations). + +## Branching & Merging + +This repository uses a **merge-based workflow**. Squash merges are not permitted. + +### SemVer Policy + +We enforce strict **Rust-Flavored SemVer** compliance. MSRV (Minimum Supported Rust +Version) bumps are considered **SemVer-breaking changes**. + +### Branching for Bug Fixes + +Maintenance branches exist for released crate versions (e.g. `maint/zcash_client_sqlite-0.18.x`, +`maint/zcash_primitives-0.26.x`). When fixing a bug in released code: + +1. Branch from the **earliest existing `maint/` branch** for the crate in question, so + that the fix can be included in a semver-compatible release. +2. If no `maint/` branch exists for the crate, branch from the latest release tag at the + Rust-flavored semver "breaking" version (i.e. `x.y.z` for `0.x` crates, or `x.0.0` + for `1.x+` crates) depended upon by the earliest `maint/zcash_client_sqlite` branch + available. +3. The fix is then forward-merged into newer `maint/` branches and eventually `main`. + +### Feature Work + +New features and non-fix changes should branch from `main`. + +## Changelog & Commit Discipline + +- Update the crate's `CHANGELOG.md` for any public API change, bug fix, or semantic change. +- Commits must be discrete semantic changes โ€” no WIP commits in final PR history. +- Each commit that alters public API must also update docs and changelog in the same commit. +- Use `git revise` to maintain clean history within a PR. +- Commit messages: short title (<120 chars), body with motivation for the change. + +## CI Checks (all must pass) + +- `cargo test --workspace` across 6 feature-flag configurations +- `cargo clippy --all-features --all-targets -- -D warnings` +- `cargo fmt --all -- --check` +- Intra-doc link validation (nightly) +- Protobuf generated code consistency +- UUID validity for SQLite migrations +- `cargo-deny` license checking, `cargo-vet` supply chain audits โ€” adding or + bumping dependencies requires new audit entries, so dependency changes have + a real review cost; avoid unnecessary dependency churn diff --git a/Cargo.lock b/Cargo.lock index 61d6367942..f9d9a5fbba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,9 +155,9 @@ checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" [[package]] name = "anyhow" -version = "1.0.83" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25bdb32cbbdce2b519a9cd7df3a678443100e265d5e25ca763b7572a5104f5f3" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arrayref" @@ -1069,12 +1069,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.20.10", - "darling_macro 0.20.10", + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -1093,9 +1093,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", @@ -1118,11 +1118,11 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core 0.20.10", + "darling_core 0.21.3", "quote", "syn 2.0.100", ] @@ -1286,6 +1286,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1464,6 +1470,22 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "eip681" +version = "0.1.0" +dependencies = [ + "base64", + "ens-normalize-rs", + "hex", + "nom", + "percent-encoding", + "pretty_assertions", + "primitive-types", + "proptest", + "sha3", + "snafu", +] + [[package]] name = "either" version = "1.13.0" @@ -1501,6 +1523,25 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "ens-normalize-rs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0712f972f7320be5977fb2f4be0312ab3e5b1e8906cb9b3ee5f988ef4dc9590f" +dependencies = [ + "anyhow", + "itertools 0.13.0", + "lazy_static", + "regex", + "serde", + "serde-aux", + "serde_json", + "serde_plain", + "serde_with", + "thiserror 2.0.12", + "unicode-normalization", +] + [[package]] name = "enum-ordinalize" version = "3.1.15" @@ -1979,9 +2020,9 @@ dependencies = [ [[package]] name = "halo2_gadgets" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73a5e510d58a07d8ed238a5a8a436fe6c2c79e1bb2611f62688bc65007b4e6e7" +checksum = "45824ce0dd12e91ec0c68ebae2a7ed8ae19b70946624c849add59f1d1a62a143" dependencies = [ "arrayvec", "bitvec", @@ -2017,14 +2058,15 @@ dependencies = [ [[package]] name = "halo2_proofs" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b867a8d9bbb85fca76fff60652b5cd19b853a1c4d0665cb89bee68b18d2caf0" +checksum = "05713f117155643ce10975e0bee44a274bcda2f4bb5ef29a999ad67c1fa8d4d3" dependencies = [ "blake2b_simd", "ff", "group", "halo2_legacy_pdqsort", + "indexmap 1.9.3", "maybe-rayon", "pasta_curves", "rand_core 0.6.4", @@ -2927,9 +2969,8 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orchard" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1ef66fcf99348242a20d582d7434da381a867df8dc155b3a980eca767c56137" +version = "0.12.0" +source = "git+https://github.com/zcash/orchard.git?rev=6b12c77260aa7fac0d804983fc31b71b584d48e0#6b12c77260aa7fac0d804983fc31b71b584d48e0" dependencies = [ "aes", "bitvec", @@ -2950,6 +2991,7 @@ dependencies = [ "pasta_curves", "proptest", "rand 0.8.5", + "rand_core 0.6.4", "reddsa", "serde", "sinsemilla", @@ -3364,6 +3406,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.20" @@ -3744,6 +3796,26 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "regex" version = "1.11.1" @@ -4054,9 +4126,8 @@ dependencies = [ [[package]] name = "sapling-crypto" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d3c081c83f1dc87403d9d71a06f52301c0aa9ea4c17da2a3435bbf493ffba4" +version = "0.6.0" +source = "git+https://github.com/zcash/sapling-crypto.git?rev=4f95c2286dbe90f05e6f44d634bc2924b992fab4#4f95c2286dbe90f05e6f44d634bc2924b992fab4" dependencies = [ "aes", "bellman", @@ -4086,6 +4157,30 @@ dependencies = [ "zip32", ] +[[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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemerz" version = "0.2.0" @@ -4184,6 +4279,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-aux" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a" +dependencies = [ + "chrono", + "serde", + "serde-value", + "serde_json", +] + [[package]] name = "serde-value" version = "0.7.0" @@ -4225,13 +4332,24 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.120" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", + "memchr", "ryu", "serde", + "serde_core", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", ] [[package]] @@ -4254,17 +4372,18 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.8.1" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad483d2ab0149d5a5ebcd9972a3852711e0153d863bf5a5d0391d28883c4a20" +checksum = "10574371d41b0d9b2cff89418eda27da52bcaff2cc8741db26382a77c29131f1" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.12.0", - "serde", - "serde_derive", + "schemars 0.9.0", + "schemars 1.1.0", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -4272,11 +4391,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.8.1" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65569b702f41443e8bc8bbb1c5779bd0450bbe723b56198980e80ec45780bce2" +checksum = "08a72d8216842fdd57820dc78d840bef99248e35fb2554ff923319e60f2d686b" dependencies = [ - "darling 0.20.10", + "darling 0.21.3", "proc-macro2", "quote", "syn 2.0.100", @@ -4440,6 +4559,27 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "socket2" version = "0.5.9" @@ -6870,6 +7010,12 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "zcash" version = "0.1.0" @@ -7074,7 +7220,9 @@ name = "zcash_encoding" version = "0.3.0" dependencies = [ "core2", + "hex", "nonempty", + "proptest", ] [[package]] @@ -7159,13 +7307,11 @@ dependencies = [ [[package]] name = "zcash_primitives" -version = "0.26.1" +version = "0.26.4" dependencies = [ "assert_matches", - "bip32", "blake2b_simd", "block-buffer 0.11.0-rc.3", - "bs58", "chacha20poly1305", "core2", "criterion 0.5.1", @@ -7173,9 +7319,6 @@ dependencies = [ "document-features", "equihash", "ff", - "fpe", - "getset", - "group", "hex", "incrementalmerkletree", "jubjub", @@ -7184,22 +7327,16 @@ dependencies = [ "orchard", "pprof", "proptest", - "rand 0.8.5", "rand_core 0.6.4", "rand_xorshift", "redjubjub", - "ripemd 0.1.3", "sapling-crypto", "secp256k1", "sha2 0.10.8", - "subtle", - "tracing", - "zcash_address", "zcash_encoding", "zcash_note_encryption", "zcash_protocol", "zcash_script", - "zcash_spec", "zcash_transparent", "zip32", ] @@ -7229,7 +7366,7 @@ dependencies = [ [[package]] name = "zcash_protocol" -version = "0.7.1" +version = "0.7.2" dependencies = [ "core2", "document-features", @@ -7238,13 +7375,14 @@ dependencies = [ "incrementalmerkletree-testing", "memuse", "proptest", + "zcash_encoding", ] [[package]] name = "zcash_script" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bed6cf5b2b4361105d4ea06b2752f0c8af4641756c7fbc9858a80af186c234f" +checksum = "c6ef9d04e0434a80b62ad06c5a610557be358ef60a98afa5dbc8ecaf19ad72e7" dependencies = [ "bip32", "bitflags 2.9.0", @@ -7268,15 +7406,15 @@ dependencies = [ [[package]] name = "zcash_transparent" -version = "0.6.1" +version = "0.6.3" dependencies = [ "bip32", - "blake2b_simd", "bs58", "core2", "document-features", "getset", "hex", + "nonempty", "proptest", "ripemd 0.1.3", "secp256k1", diff --git a/Cargo.toml b/Cargo.toml index 0c18c7d336..f001f9cdc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "components/eip681", "components/equihash", "components/f4jumble", "components/zcash_address", @@ -39,8 +40,8 @@ equihash = { version = "0.2", path = "components/equihash", default-features = f zcash_address = { version = "0.10", path = "components/zcash_address", default-features = false } zcash_client_backend = { version = "0.21", path = "zcash_client_backend" } zcash_encoding = { version = "0.3", path = "components/zcash_encoding", default-features = false } -zcash_keys = { version = "0.12", path = "zcash_keys" } -zcash_protocol = { version = "0.7", path = "components/zcash_protocol", default-features = false } +zcash_keys = { version = "0.12", path = "zcash_keys", default-features = false } +zcash_protocol = { version = "0.7.2", path = "components/zcash_protocol", default-features = false } zip321 = { version = "0.6", path = "components/zip321" } zcash_note_encryption = "0.4.1" @@ -64,10 +65,10 @@ blake2s_simd = { version = "1", default-features = false } bls12_381 = "0.8" jubjub = "0.10" redjubjub = { version = "0.8", default-features = false } -sapling = { package = "sapling-crypto", version = "0.5", default-features = false } +sapling = { package = "sapling-crypto", version = "0.6", default-features = false } # - Orchard -orchard = { version = "0.11", default-features = false } +orchard = { version = "0.12", default-features = false } pasta_curves = "0.5" # - Transparent @@ -76,8 +77,8 @@ block-buffer = { version = "=0.11.0-rc.3" } # later RCs require edition2024 crypto-common = { version = "=0.2.0-rc.1" } # later RCs require edition2024 ripemd = { version = "0.1", default-features = false } secp256k1 = { version = "0.29", default-features = false, features = ["alloc"] } -transparent = { package = "zcash_transparent", version = "0.6", path = "zcash_transparent", default-features = false } -zcash_script = { version = "0.4.2", default-features = false } +transparent = { package = "zcash_transparent", version = "0.6.2", path = "zcash_transparent", default-features = false } +zcash_script = { version = "0.4.3", default-features = false } zeroize = { version = "1.7", default-features = false } # Boilerplate & missing stdlib @@ -99,13 +100,14 @@ sha2 = { version = "0.10", default-features = false } # Documentation document-features = "0.2" -# Encodings +# Encodings and Parsing base64 = "0.22" bech32 = { version = "0.11", default-features = false, features = ["alloc"] } bitflags = "2" bs58 = { version = "0.5", default-features = false, features = ["alloc", "check"] } byteorder = "1" hex = { version = "0.4", default-features = false, features = ["alloc"] } +nom = "7" percent-encoding = "2.1.0" postcard = { version = "1", default-features = false, features = ["alloc"] } serde = { version = "1", default-features = false, features = ["derive"] } @@ -226,3 +228,7 @@ unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(zcash_unstable, values("zfuture", "nu7"))', 'cfg(live_network_tests)', ] } + +[patch.crates-io] +sapling = { package = "sapling-crypto", git = "https://github.com/zcash/sapling-crypto.git", rev = "4f95c2286dbe90f05e6f44d634bc2924b992fab4" } +orchard = { package = "orchard", git = "https://github.com/zcash/orchard.git", rev = "6b12c77260aa7fac0d804983fc31b71b584d48e0" } diff --git a/README.md b/README.md index 635a6a6053..552ada37d4 100644 --- a/README.md +++ b/README.md @@ -3,101 +3,168 @@ This repository contains a (work-in-progress) set of Rust crates for working with Zcash. + + ```mermaid graph TB subgraph librustzcash direction TB subgraph main - zcash_address - zcash_primitives - zcash_transparent - zcash_proofs - zcash_protocol - pczt - zcash_client_backend - zcash_client_sqlite - zcash_keys - zip321 + zcash_address + zcash_primitives + zcash_transparent + zcash_proofs + zcash_extensions + zcash_protocol + pczt + zcash_client_backend + zcash_client_memory + zcash_client_sqlite + zcash_keys + zip321 end subgraph standalone_components - equihash - f4jumble - zcash_encoding + equihash + f4jumble + zcash_encoding end end subgraph shielded_protocols - sapling[sapling-crypto] - orchard[orchard] + sapling-crypto + orchard end subgraph protocol_components - zcash_note_encryption - zip32 - zcash_spec + zcash_note_encryption + zip32 + zcash_spec end + %% Direct edges that also have a transitive path are left in (so the linter checks + %% they are deliberate edges) but commented out (to simplify the graph). + zcash_client_sqlite --> zcash_client_backend - zcash_client_backend --> zcash_primitives - zcash_client_backend --> zip321 - zcash_client_backend --> zcash_keys + zcash_client_memory --> zcash_client_backend + zcash_client_backend --> pczt - pczt --> zcash_primitives + + %% zcash_client_sqlite --> zcash_keys + %% zcash_client_memory --> zcash_keys + zcash_client_backend --> zcash_keys + + zcash_client_backend --> zcash_proofs + + zcash_extensions --> zcash_primitives zcash_proofs --> zcash_primitives - zcash_primitives --> zcash_protocol - zcash_primitives --> equihash - zcash_primitives --> zcash_encoding - zcash_primitives --> zcash_address - zcash_primitives --> zcash_transparent - zcash_primitives --> sapling - zcash_primitives --> orchard - zcash_keys --> zcash_address - zcash_keys --> zcash_encoding - zcash_keys --> zip32 + pczt --> zcash_primitives + %% zcash_client_backend --> zcash_primitives + %% zcash_client_sqlite --> zcash_primitives + %% zcash_client_memory --> zcash_primitives + + %% zcash_client_sqlite --> zcash_transparent + %% zcash_client_memory --> zcash_transparent + %% zcash_client_backend --> zcash_transparent + %% pczt --> zcash_transparent zcash_keys --> zcash_transparent - zcash_keys --> orchard - zcash_keys --> sapling - zcash_transparent --> zcash_protocol + zcash_primitives --> zcash_transparent + + zcash_client_backend --> zip321 + zcash_transparent --> zcash_address - zcash_transparent --> zip32 + %% zcash_keys --> zcash_address + %% zcash_client_backend --> zcash_address + %% zcash_client_sqlite --> zcash_address + %% zcash_client_memory --> zcash_address zip321 --> zcash_address - zip321 --> zcash_protocol + + %% zcash_transparent --> zcash_protocol + %% zcash_keys --> zcash_protocol + %% zcash_client_sqlite --> zcash_protocol + %% zcash_client_memory --> zcash_protocol + %% zcash_primitives --> zcash_protocol + %% zcash_client_backend --> zcash_protocol + %% pczt --> zcash_protocol + %% zcash_extensions --> zcash_protocol zcash_address --> zcash_protocol + %% zip321 --> zcash_protocol + + %% zcash_client_sqlite --> zcash_encoding + %% zcash_transparent --> zcash_encoding + %% zcash_client_memory --> zcash_encoding + %% zcash_client_backend --> zcash_encoding + %% zcash_keys --> zcash_encoding + %% zcash_primitives --> zcash_encoding + %% zcash_address --> zcash_encoding + zcash_protocol --> zcash_encoding + + zcash_primitives --> equihash zcash_address --> f4jumble - zcash_address --> zcash_encoding - sapling --> zcash_note_encryption - sapling --> zip32 - sapling --> zcash_spec + + %% zcash_client_backend --> orchard + %% pczt --> orchard + zcash_keys --> orchard + zcash_primitives --> orchard + %% zcash_client_sqlite --> orchard + %% zcash_client_memory --> orchard + + %% zcash_client_sqlite --> sapling-crypto + %% zcash_client_memory --> sapling-crypto + %% zcash_client_backend --> sapling-crypto + zcash_keys --> sapling-crypto + %% zcash_proofs --> sapling-crypto + zcash_primitives --> sapling-crypto + %% pczt --> sapling-crypto + orchard --> zcash_note_encryption + sapling-crypto --> zcash_note_encryption + %% zcash_primitives --> zcash_note_encryption + %% pczt --> zcash_note_encryption + %% zcash_client_backend --> zcash_note_encryption + + %% zcash_client_sqlite --> zip32 + %% zcash_client_memory --> zip32 + %% zcash_client_backend --> zip32 + %% zcash_keys --> zip32 orchard --> zip32 - orchard --> zcash_spec + sapling-crypto --> zip32 + zcash_transparent --> zip32 + + %% zcash_transparent --> zcash_spec + %% orchard --> zcash_spec + %% sapling-crypto --> zcash_spec + zip32 --> zcash_spec main --> standalone_components librustzcash --> shielded_protocols shielded_protocols --> protocol_components + click equihash "https://docs.rs/equihash/" _blank + click f4jumble "https://docs.rs/f4jumble/" _blank + click orchard "https://docs.rs/orchard/" _blank + click pczt "https://docs.rs/pczt/" _blank + click sapling-crypto "https://docs.rs/sapling-crypto/" _blank click zcash_address "https://docs.rs/zcash_address/" _blank + click zcash_encoding "https://docs.rs/zcash_encoding/" _blank + click zcash_client_backend "https://docs.rs/zcash_client_backend/" _blank + click zcash_client_memory "https://docs.rs/zcash_client_memory/" _blank + click zcash_client_sqlite "https://docs.rs/zcash_client_sqlite/" _blank + click zcash_extensions "https://docs.rs/zcash_extensions/" _blank + click zcash_keys "https://docs.rs/zcash_keys/" _blank + click zcash_note_encryption "https://docs.rs/zcash_note_encryption/" _blank click zcash_primitives "https://docs.rs/zcash_primitives/" _blank - click zcash_transparent "https://docs.rs/zcash_transparent/" _blank click zcash_proofs "https://docs.rs/zcash_proofs/" _blank click zcash_protocol "https://docs.rs/zcash_protocol/" _blank - click zcash_keys "https://docs.rs/zcash_keys/" _blank + click zcash_spec "https://docs.rs/zcash_spec/" _blank + click zcash_transparent "https://docs.rs/zcash_transparent/" _blank click zip321 "https://docs.rs/zip321/" _blank - click pczt "https://docs.rs/pczt/" _blank - click zcash_client_backend "https://docs.rs/zcash_client_backend/" _blank - click zcash_client_sqlite "https://docs.rs/zcash_client_sqlite/" _blank - click equihash "https://docs.rs/equihash/" _blank - click f4jumble "https://docs.rs/f4jumble/" _blank - click zcash_encoding "https://docs.rs/zcash_encoding/" _blank - click sapling "https://docs.rs/sapling-crypto/" _blank - click orchard "https://docs.rs/orchard/" _blank - click zcash_note_encryption "https://docs.rs/zcash_note_encryption/" _blank click zip32 "https://docs.rs/zip32/" _blank - click zcash_spec "https://docs.rs/zcash_spec/" _blank ``` + + ### Crates [librustzcash: a Rust Crates Walkthrough w/ nuttycom](https://free2z.cash/uploadz/public/ZcashTutorial/librustzcash-a-rust-crates.mp4) diff --git a/components/eip681/CHANGELOG.md b/components/eip681/CHANGELOG.md new file mode 100644 index 0000000000..bd5da272ba --- /dev/null +++ b/components/eip681/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this library will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this library adheres to Rust's notion of +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). Future releases are +indicated by the `PLANNED` status in order to make it possible to correctly +represent the transitive `semver` implications of changes within the enclosing +workspace. + +## [Unreleased] + +## [0.1.0] - Tue 31 March 2026 + +- Initial release! diff --git a/components/eip681/Cargo.toml b/components/eip681/Cargo.toml new file mode 100644 index 0000000000..762b11d6e6 --- /dev/null +++ b/components/eip681/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "eip681" +description = "Parsing functions and data types for Ethereum EIP 681 Payment Request URIs" +version = "0.1.0" +authors = [ + "Schell Scivally " +] +homepage = "https://github.com/zcash/librustzcash" +edition.workspace = true +readme = "README.md" +rust-version.workspace = true +repository.workspace = true +license.workspace = true +categories.workspace = true + +[dependencies] +# - Error handling +snafu = "0.8.6" + +# - Large integers +primitive-types = { version = "0.12", default-features = false } + +# - Parsing and Encoding +base64.workspace = true +ens-normalize-rs = "0.1.1" +hex.workspace = true +nom.workspace = true +percent-encoding.workspace = true +sha3 = "0.10.8" + +# - Test dependencies +proptest = { workspace = true, optional = true } + +[dev-dependencies] +pretty_assertions = "1.4.1" +proptest.workspace = true + +[features] +## Exposes APIs that are useful for testing, such as `proptest` strategies. +test-dependencies = ["dep:proptest"] + +[lints] +workspace = true diff --git a/components/eip681/README.md b/components/eip681/README.md new file mode 100644 index 0000000000..1a6447e9a4 --- /dev/null +++ b/components/eip681/README.md @@ -0,0 +1,21 @@ +# eip681 + +This library contains Rust parsing functions and data types for working +with [Ethereum EIP 681 Payment Request URIs](https://eips.ethereum.org/EIPS/eip-681). + +## License + +Licensed under either of + + * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or + http://www.apache.org/licenses/LICENSE-2.0) + * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your discretion. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally +submitted for inclusion in the work by you, as defined in the Apache-2.0 +license, shall be dual licensed as above, without any additional terms or +conditions. diff --git a/components/eip681/src/error.rs b/components/eip681/src/error.rs new file mode 100644 index 0000000000..0637d8356d --- /dev/null +++ b/components/eip681/src/error.rs @@ -0,0 +1,278 @@ +//! Any and all errors encountered during parsing and validation. + +use snafu::prelude::*; + +use crate::parse::Value; + +/// Errors discovered during parsing. +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] +#[non_exhaustive] +pub enum ParseError<'a> { + /// A parsing error generated by `nom`. + #[snafu(display("{}", nom::error::Error::to_string(&nom::error::Error { + input: *input, code: *code + })))] + Nom { + input: &'a str, + code: nom::error::ErrorKind, + other: Option, + }, + + /// The number of digits did not match or exceed the expected minimum. + #[snafu(display( + "Expected at least {min} digits, saw {digits_len} before {}", + input.split_at(10).0 + ))] + DigitsMinimum { + min: usize, + digits_len: usize, + input: &'a str, + }, + + /// The character is not a hexadecimal digit. + #[snafu(display("The character '{character}' was expected to be hexadecimal, but isn't."))] + NotAHexDigit { character: char }, + + /// The ENS name was expected and missing. + #[snafu(display("Missing ENS name"))] + EnsMissing, + + /// The ENS name was not a valid domain. + #[snafu(display("Not a domain"))] + EnsDomain, + + /// The ENS name could not be normalized. + #[snafu(display("{source}"))] + EnsNormalization { + source: ens_normalize_rs::ProcessError, + }, + + /// The ENS should be normalized, but wasn't. + #[snafu(display("ENS name must be normalized. Expected '{expected}' but saw '{seen}'"))] + NotNormalized { expected: String, seen: String }, + + /// A parameter value had an unexpected value. + #[snafu(display("Invalid parameter value. Expected a {ty}."))] + InvalidParameterValue { ty: String }, + + /// A parameter key had an unexpected value. + #[snafu(display("Invalid parameter key '{key}'."))] + InvalidParameterKey { key: String }, + + /// Unexpected leftover input after parsing. + #[snafu(display("Unexpected leftover input: {input}"))] + UnexpectedLeftoverInput { input: &'a str }, +} + +impl<'a> nom::error::ParseError<&'a str> for ParseError<'a> { + fn from_error_kind(input: &'a str, code: nom::error::ErrorKind) -> Self { + NomSnafu { + input, + code, + other: None, + } + .build() + } + + fn append(input: &'a str, code: nom::error::ErrorKind, other: Self) -> Self { + NomSnafu { + input, + code, + other: Some(other.to_string()), + } + .build() + } +} + +impl<'a> From> for nom::Err> { + fn from(value: ParseError<'a>) -> Self { + nom::Err::Error(value) + } +} + +/// All the reasons an ERC-55 check-summed address might fail validation. +#[derive(Debug, PartialEq)] +#[non_exhaustive] +pub enum Erc55ValidationFailureReason { + /// The input failed validation, but is all lowercase, and may not have been check-summed. + AllLowercase, + /// The input failed validation, but is all upper, and may not have been check-summed. + AllUppercase, + /// The input failed validation. + ChecksumDoesNotMatch { expected: String, saw: String }, +} + +/// Errors discovered after parsing, usually when attempting to convert parsed +/// types into numeric types. +#[derive(Debug, Snafu, PartialEq)] +#[non_exhaustive] +#[snafu(visibility(pub(crate)))] +pub enum ValidationError { + #[snafu(display( + "Incorrect number of digits for an ethereum address. Expected 40, saw {len}." + ))] + IncorrectEthAddressLen { len: usize }, + + #[snafu(display("Exponent is too small, expected at least {expected}, saw {seen}"))] + SmallExponent { expected: usize, seen: u64 }, + + #[snafu(display("Exponent is too large, expected at most {expected}, saw {seen}"))] + LargeExponent { expected: usize, seen: u64 }, + + #[snafu(display( + "Arithmetic operation resulted in an overflow, exceeding the allowable range" + ))] + Overflow, + + #[snafu(display("Cannot convert negative number to unsigned type"))] + NegativeValueForUnsignedType, + + #[snafu(display("Could not decode url-encoded string: {source}"))] + UrlEncoding { source: std::str::Utf8Error }, + + /// An attempt to convert an integer failed. + #[snafu(display("Integer conversion failed: {source}"))] + Integer { source: std::num::TryFromIntError }, + + /// More than one value exists in a parameter list with a key + /// that denotes a single value. + #[snafu(display( + "More than one parameter by the key '{key}', saw: [{}]", + values.iter().map(|(k, v)| format!("{k}={v}")).collect::>().join(", ") + ))] + MultipleParameterValues { + key: &'static str, + values: Vec<(&'static str, Value)>, + }, + + /// An Ethereum address failed ERC-55 checksum validation. + #[snafu(display("ERC-55 checksum validation failure: {reason:?}"))] + Erc55Validation { + reason: Erc55ValidationFailureReason, + }, +} + +/// Errors encountered while converting [`RawTransactionRequest`] into +/// [`Erc20Request`]. +/// +/// [`RawTransactionRequest`]: crate::parse::RawTransactionRequest +/// [`Erc20Request`]: crate::Erc20Request +#[derive(Debug, Snafu)] +#[non_exhaustive] +#[snafu(visibility(pub(crate)))] +pub enum Erc20Error { + #[snafu(display("Transaction is missing the function name"))] + MissingFn, + + #[snafu(display("Expected function name to be 'transfer', saw '{seen}'"))] + FnName { seen: String }, + + #[snafu(display("Incorrect number of ABI parameters, expected 2, saw '{seen}'"))] + AbiParameterLen { seen: usize }, + + #[snafu(display( + r#"Incorrect ABI parameter names, \ + expected "address" and "uint256" - saw "{param1}" and "{param2}""# + ))] + AbiParameterName { param1: String, param2: String }, + + #[snafu(display("The 'address' parameter value is not an address"))] + AbiParameterAddressIsNotAnAddress, + + #[snafu(display("The 'uint256' parameter is not a number"))] + AbiParameterUint256, + + #[snafu(display("The 'uint256' parameter could not be converted: {source}"))] + AbiParameterUint256Conversion { source: std::num::TryFromIntError }, +} + +/// Errors encountered while converting [`RawTransactionRequest`] into [`NativeRequest`]. +/// +/// [`RawTransactionRequest`]: crate::parse::RawTransactionRequest +/// [`NativeRequest`]: crate::NativeRequest +#[derive(Debug, Snafu)] +#[non_exhaustive] +#[snafu(visibility(pub(crate)))] +pub enum NativeTransferError { + #[snafu(display("Has a function name"))] + HasFunctionName, + + #[snafu(display("Request has ABI parameters"))] + HasAbiParameters, + + #[snafu(display("Parameter '{key}' is not a number"))] + ParameterNotNumber { key: String }, + + #[snafu(display("Parameter '{key}' value is invalid: {source}"))] + ParameterInvalid { + key: String, + source: ValidationError, + }, + + #[snafu(display("Invalid chain ID: {source}"))] + ChainIdInvalid { source: ValidationError }, + + #[snafu(display("Invalid recipient address: {source}"))] + RecipientAddressInvalid { source: ValidationError }, +} + +/// Top-level errors for the transaction request API. +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] +#[non_exhaustive] +pub enum Error { + /// An error occurred during parsing. + #[snafu(display("Parsing error: {message}"))] + Parse { message: String }, + + /// An error occurred during validation. + #[snafu(display("Validation error: {source}"))] + Validation { source: ValidationError }, + + /// The provided address string is not a valid Ethereum address. + #[snafu(display("Invalid Ethereum address: {address}"))] + InvalidAddress { address: String }, + + /// The request is not a valid ERC-20 transfer. + #[snafu(display("Not an ERC-20 transfer request: {source}"))] + NotErc20Transfer { source: Erc20Error }, + + /// The request is not a valid native transfer. + #[snafu(display("Not a native transfer request: {source}"))] + NotNativeTransfer { source: NativeTransferError }, +} + +impl From for Error { + fn from(source: ValidationError) -> Self { + Error::Validation { source } + } +} + +impl From for Error { + fn from(source: Erc20Error) -> Self { + Error::NotErc20Transfer { source } + } +} + +impl From for Error { + fn from(source: NativeTransferError) -> Self { + Error::NotNativeTransfer { source } + } +} + +impl From> for Error { + fn from(value: ParseError<'_>) -> Self { + Error::Parse { + message: value.to_string(), + } + } +} + +impl<'a> From>> for Error { + fn from(value: nom::Err>) -> Self { + Error::Parse { + message: value.to_string(), + } + } +} diff --git a/components/eip681/src/lib.rs b/components/eip681/src/lib.rs new file mode 100644 index 0000000000..43b3378159 --- /dev/null +++ b/components/eip681/src/lib.rs @@ -0,0 +1,66 @@ +//! Parser for [EIP-681](https://eips.ethereum.org/EIPS/eip-681) transaction requests. +//! +//! This crate provides two levels of API: +//! +//! 1. **High-level API** (this module): [`TransactionRequest`], [`NativeRequest`], and +//! [`Erc20Request`] for common use cases. +//! 2. **Low-level parsing API** ([`parse`] module): Direct access to the full EIP-681 grammar +//! via [`parse::RawTransactionRequest`]. +//! +//! ## Quick Start +//! +//! ```rust +//! use eip681::TransactionRequest; +//! +//! let uri = "ethereum:0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359?value=2.014e18"; +//! let request = TransactionRequest::parse(uri).unwrap(); +//! +//! match request { +//! TransactionRequest::NativeRequest(native) => { +//! println!("Native transfer to {}", native.recipient_address()); +//! } +//! TransactionRequest::Erc20Request(erc20) => { +//! println!("ERC-20 transfer to {}", erc20.recipient_address()); +//! } +//! TransactionRequest::Unrecognised(raw) => { +//! println!("Other request: {}", raw); +//! } +//! } +//! ``` +//! +//! ## ABNF Syntax +//! +//! The underlying parser implements the following grammar: +//! +//! ```abnf +//! request = schema_prefix target_address [ "@" chain_id ] [ "/" function_name ] [ "?" parameters ] +//! schema_prefix = "ethereum" ":" [ "pay-" ] +//! target_address = ethereum_address +//! chain_id = 1*DIGIT +//! function_name = STRING +//! ethereum_address = ( "0x" 40*HEXDIG ) / ENS_NAME +//! parameters = parameter *( "&" parameter ) +//! parameter = key "=" value +//! key = "value" / "gas" / "gasLimit" / "gasPrice" / TYPE +//! value = number / ethereum_address / STRING +//! number = [ "-" / "+" ] *DIGIT [ "." 1*DIGIT ] [ ( "e" / "E" ) [ 1*DIGIT ] ] +//! ``` +//! +//! Where `TYPE` is a standard ABI type name, as defined in Ethereum Contract +//! ABI specification. `STRING` is a URL-encoded unicode string of arbitrary +//! length, where delimiters and the percentage symbol (%) are mandatorily +//! hex-encoded with a % prefix. +//! +//! For the syntax of `ENS_NAME`, please consult +//! [ERC-137](https://eips.ethereum.org/EIPS/eip-137) defining Ethereum Name Service. +//! +//! See +//! [ABNF core rules](https://en.wikipedia.org/wiki/Augmented_Backus%E2%80%93Naur_form#Core_rules) +//! for general information about the `ABNF` format. +pub use primitive_types::U256; + +pub mod error; +pub mod parse; + +mod request; +pub use request::{Erc20Request, NativeRequest, TransactionRequest}; diff --git a/components/eip681/src/parse.rs b/components/eip681/src/parse.rs new file mode 100644 index 0000000000..9ad89ecb15 --- /dev/null +++ b/components/eip681/src/parse.rs @@ -0,0 +1,2083 @@ +//! Types and functions used for parsing. + +use std::{borrow::Cow, collections::BTreeMap}; + +use nom::{ + AsChar, Parser, + branch::alt, + bytes::complete::{is_not, tag, take_till, take_till1, take_while, take_while1}, + character::complete::{char, digit0}, + combinator::{map_parser, opt, success, value}, + multi::separated_list0, + sequence::{preceded, separated_pair, terminated, tuple}, +}; +use primitive_types::U256; +use sha3::{Digest, Keccak256}; +use snafu::{OptionExt, ResultExt}; + +use crate::error::*; + +/// Succeeds if all the input has been consumed by its child parser. +/// +/// Identical to [`nom::combinator::all_consuming`] except it only accepts `&str` inputs +/// and returns a different error. +fn all_consuming<'i, O, F>( + mut f: F, +) -> impl FnMut(&'i str) -> nom::IResult<&'i str, O, ParseError<'i>> +where + F: Parser<&'i str, O, ParseError<'i>>, +{ + move |input| { + let (input, res) = f.parse(input)?; + snafu::ensure!(input.is_empty(), UnexpectedLeftoverInputSnafu { input }); + Ok((input, res)) + } +} + +/// Zero or more consecutive digits. +/// +/// ```abnf +/// *DIGIT +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Digits { + places: Vec, +} + +impl core::fmt::Display for Digits { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for n in self.places.iter() { + f.write_fmt(format_args!("{n}"))?; + } + Ok(()) + } +} + +impl Digits { + #[cfg(any(test, feature = "test-dependencies"))] + pub const MAX_PLACES: usize = u64::MAX.ilog10() as usize; + + /// Construct a new `Digits` from a `u64`. + pub fn from_u64(mut v: u64) -> Self { + if v == 0 { + return Digits { places: vec![0] }; + } + + let mut places: Vec = vec![]; + while v > 0 { + places.push((v % 10) as u8); + v /= 10; + } + places.reverse(); + Digits { places } + } + + /// Returns the `u64` representation. + /// + /// ## Errors + /// Errors if internal arithmetic operations overflow. + pub fn as_u64(&self) -> Result { + let mut total = 0u64; + for digit in &self.places { + total = total + .checked_mul(10) + .context(OverflowSnafu)? + .checked_add(*digit as u64) + .context(OverflowSnafu)?; + } + Ok(total) + } + + /// Returns the [`U256`] representation. + /// + /// ## Errors + /// Errors if internal arithmetic operations overflow. + pub fn as_uint256(&self) -> Result { + let mut total = U256::zero(); + for digit in &self.places { + total = total + .checked_mul(U256::from(10u64)) + .context(OverflowSnafu)? + .checked_add(U256::from(*digit)) + .context(OverflowSnafu)?; + } + Ok(total) + } + + #[cfg(test)] + /// Returns the ratio corresponding to the decimal number `0.`, + /// i.e. the denominator will be `10^len(digits)`. + fn as_decimal_ratio(&self) -> Result<(u64, u64), ValidationError> { + let denominator = 10u64 + .checked_pow(u32::try_from(self.places.len()).context(IntegerSnafu)?) + .context(OverflowSnafu)?; + Ok((self.as_u64()?, denominator)) + } + + /// Parse at least `min` digits. + pub fn parse_min(min: usize) -> impl Fn(&str) -> nom::IResult<&str, Self, ParseError<'_>> { + move |i| { + let (i, chars) = digit0(i)?; + let places = chars + .chars() + .map(|c| { + c.to_digit(10) + .expect("we already checked that this char was a digit") + as u8 + }) + .collect::>(); + snafu::ensure!( + places.len() >= min, + DigitsMinimumSnafu { + min, + digits_len: places.len(), + input: i + } + ); + Ok((i, Digits { places })) + } + } +} + +/// One case-sensitive hexadecimal digit. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)] +pub enum CaseSensitiveHexDigit { + Zero, + One, + Two, + Three, + Four, + Five, + Six, + Seven, + Eight, + Nine, + UpperA, + UpperB, + UpperC, + UpperD, + UpperE, + UpperF, + LowerA, + LowerB, + LowerC, + LowerD, + LowerE, + LowerF, +} + +impl core::fmt::Display for CaseSensitiveHexDigit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + CaseSensitiveHexDigit::Zero => "0", + CaseSensitiveHexDigit::One => "1", + CaseSensitiveHexDigit::Two => "2", + CaseSensitiveHexDigit::Three => "3", + CaseSensitiveHexDigit::Four => "4", + CaseSensitiveHexDigit::Five => "5", + CaseSensitiveHexDigit::Six => "6", + CaseSensitiveHexDigit::Seven => "7", + CaseSensitiveHexDigit::Eight => "8", + CaseSensitiveHexDigit::Nine => "9", + CaseSensitiveHexDigit::LowerA => "a", + CaseSensitiveHexDigit::LowerB => "b", + CaseSensitiveHexDigit::LowerC => "c", + CaseSensitiveHexDigit::LowerD => "d", + CaseSensitiveHexDigit::LowerE => "e", + CaseSensitiveHexDigit::LowerF => "f", + CaseSensitiveHexDigit::UpperA => "A", + CaseSensitiveHexDigit::UpperB => "B", + CaseSensitiveHexDigit::UpperC => "C", + CaseSensitiveHexDigit::UpperD => "D", + CaseSensitiveHexDigit::UpperE => "E", + CaseSensitiveHexDigit::UpperF => "F", + }) + } +} + +impl CaseSensitiveHexDigit { + /// Attempt to create a new `CaseSensitiveHexDigit` from a character. + /// + /// Fails if `c` is not in `0..=9`, `a..=f` or `A..=F`. + pub fn from_char(c: char) -> Result> { + Ok(match c { + '0' => CaseSensitiveHexDigit::Zero, + '1' => CaseSensitiveHexDigit::One, + '2' => CaseSensitiveHexDigit::Two, + '3' => CaseSensitiveHexDigit::Three, + '4' => CaseSensitiveHexDigit::Four, + '5' => CaseSensitiveHexDigit::Five, + '6' => CaseSensitiveHexDigit::Six, + '7' => CaseSensitiveHexDigit::Seven, + '8' => CaseSensitiveHexDigit::Eight, + '9' => CaseSensitiveHexDigit::Nine, + 'a' => CaseSensitiveHexDigit::LowerA, + 'b' => CaseSensitiveHexDigit::LowerB, + 'c' => CaseSensitiveHexDigit::LowerC, + 'd' => CaseSensitiveHexDigit::LowerD, + 'e' => CaseSensitiveHexDigit::LowerE, + 'f' => CaseSensitiveHexDigit::LowerF, + 'A' => CaseSensitiveHexDigit::UpperA, + 'B' => CaseSensitiveHexDigit::UpperB, + 'C' => CaseSensitiveHexDigit::UpperC, + 'D' => CaseSensitiveHexDigit::UpperD, + 'E' => CaseSensitiveHexDigit::UpperE, + 'F' => CaseSensitiveHexDigit::UpperF, + character => NotAHexDigitSnafu { character }.fail()?, + }) + } +} + +/// Zero or more consecutive and case-sensitive hexadecimal digits. +/// +/// ```abnf +/// *HEXDIG +/// ``` +#[derive(Clone, PartialEq)] +pub struct HexDigits { + places: Vec, +} + +impl std::fmt::Debug for HexDigits { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HexDigits") + .field( + "places", + &self + .places + .iter() + .map(|digit| digit.to_string()) + .collect::>() + .concat(), + ) + .finish() + } +} + +impl core::fmt::Display for HexDigits { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for n in self.places.iter() { + f.write_fmt(format_args!("{n}"))?; + } + Ok(()) + } +} + +impl HexDigits { + /// Parse at least `min` digits. + pub fn parse_min(min: usize) -> impl Fn(&str) -> nom::IResult<&str, Self, ParseError<'_>> { + move |i| { + let (i, data) = take_while(|c: char| c.is_ascii_hexdigit())(i)?; + snafu::ensure!( + data.len() >= min, + DigitsMinimumSnafu { + min, + digits_len: data.len(), + input: i + } + ); + let mut places = vec![]; + for c in data.chars() { + let digit = CaseSensitiveHexDigit::from_char(c)?; + places.push(digit); + } + Ok((i, HexDigits { places })) + } + } + + /// Returns whether all alphabetic digits are lowercase. + pub fn is_all_lowercase(&self) -> bool { + let uppers = CaseSensitiveHexDigit::UpperA..=CaseSensitiveHexDigit::UpperF; + for digit in self.places.iter() { + if uppers.contains(digit) { + return false; + } + } + true + } + + /// Returns whether all alphabetic digits are uppercase. + pub fn is_all_uppercase(&self) -> bool { + let lowers = CaseSensitiveHexDigit::LowerA..=CaseSensitiveHexDigit::LowerF; + for digit in self.places.iter() { + if lowers.contains(digit) { + return false; + } + } + true + } + + /// Validates the digits as an Ethereum address according to ERC-55, if possible. + pub fn validate_erc55(&self) -> Result<(), ValidationError> { + snafu::ensure!( + self.places.len() == 40, + IncorrectEthAddressLenSnafu { + len: self.places.len() + } + ); + + // Construct an ERC-55 checksummed address and compare that to the input + let address_input = self.to_string(); + let address_lowercase = address_input.to_ascii_lowercase(); + let address_hashed = hex::encode(Keccak256::digest(&address_lowercase)); + let address_checksummed = address_lowercase + .chars() + .enumerate() + .map(|(i, c)| { + if c.is_ascii_digit() { + c + } else { + let should_uppercase = u8::from_str_radix(&address_hashed[i..i + 1], 16) + .expect("Always a hexadecimal ASCII char") + >= 8; + if should_uppercase { + c.to_uppercase().next().expect("Always the char") + } else { + c + } + } + }) + .collect::(); + snafu::ensure!( + address_input == address_checksummed, + Erc55ValidationSnafu { + reason: { + // It's possible that this was not a checksummed address. It's pretty rare that a checksummed + // address is all lowercase or all uppercase (a probability of 0.0004942706034105575) + if self.is_all_lowercase() { + Erc55ValidationFailureReason::AllLowercase + } else if self.is_all_uppercase() { + Erc55ValidationFailureReason::AllUppercase + } else { + Erc55ValidationFailureReason::ChecksumDoesNotMatch { + expected: address_checksummed, + saw: address_input, + } + } + } + } + ); + + Ok(()) + } +} + +/// A parsed number. +/// +/// ```abnf +/// number = [ "-" / "+" ] *DIGIT [ "." 1*DIGIT ] [ ( "e" / "E" ) [ 1*DIGIT ] ] +/// ``` +/// +/// A number can be expressed in scientific notation, with a +/// multiplier of a power of 10. Only integer numbers are allowed, so the +/// exponent MUST be greater or equal to the number of decimals after the point. +/// +/// ## Warning +/// The ABNF notation is ambiguous, it allows for quite a few cases that don't +/// make sense - for example: +/// +/// 1. `*DIGIT` is missing and `[ "." 1*DIGIT ]` is present while `[ ( "e" / "E" ) [ 1*DIGIT] ]` +/// is not +/// 2. `[ ( "e" / "E" ) [ 1*DIGIT] ]` is missing the `[ 1*DIGIT ]` (eg, just "e") +/// 3. only `"-" / "+"` is present +/// +/// For this reason, in this library, parsing is separate from validation. +/// +/// Other implementations use regular expressions instead of parsing, and only +/// support very specific values. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Number { + /// true for "+", false for "-" + signum: Option, + integer: Digits, + decimal: Option, + exponent: Option<( + // true for "e", false for "E" + bool, + Option, + )>, +} + +impl core::fmt::Display for Number { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let this = &self; + let Number { + signum, + integer, + decimal, + exponent, + } = this; + if let Some(signum) = signum { + f.write_str(if *signum { "+" } else { "-" })?; + } + integer.fmt(f)?; + if let Some(dec) = decimal { + f.write_fmt(format_args!(".{dec}"))?; + }; + if let Some((little_e, maybe_exp)) = exponent { + f.write_str(if *little_e { "e" } else { "E" })?; + if let Some(exp) = maybe_exp { + exp.fmt(f)?; + } + } + Ok(()) + } +} + +impl Number { + /// Parse a `Number`. + /// + /// ```abnf + /// number = [ "-" / "+" ] *DIGIT [ "." 1*DIGIT ] [ ( "e" / "E" ) [ 1*DIGIT ] ] + /// ``` + pub fn parse(i: &str) -> nom::IResult<&str, Self, ParseError<'_>> { + // Parse [ "-" / "+" ], returning `true` if "+" + let parse_signum = opt(char('+').map(|_| true).or(char('-').map(|_| false))); + + // Parse *DIGIT + let parse_integer = Digits::parse_min(0); + + // Parse [ "." 1*DIGIT ] + let parse_decimal = opt(preceded(char('.'), Digits::parse_min(1))); + + // Parse [ ( "e" / "E" ) [ 1*DIGIT ] ] + fn parse_exponent( + i: &str, + ) -> nom::IResult<&str, Option<(bool, Option)>, ParseError<'_>> { + // Parse ( "e" / "E" ), returning `true` if "e" + let parse_e = char('e').map(|_| true).or(char('E').map(|_| false)); + + // Parse [ 1*DIGIT ] + let maybe_exp = opt(Digits::parse_min(1)); + + opt(tuple((parse_e, maybe_exp))).parse(i) + } + + tuple((parse_signum, parse_integer, parse_decimal, parse_exponent)) + .map(|(signum, integer, decimal, exponent)| Self { + signum, + integer, + decimal, + exponent, + }) + .parse(i) + } + + /// Returns the value of the integer portion of the number. + /// + /// ## Errors + /// Errors if internal arithmetic operations overflow. + fn integer(&self) -> Result { + self.integer.as_u64() + } + + /// Convert this [`Number`] into an i128, if possible. + /// + /// ## Errors + /// Errors if internal arithmetic operations overflow. + pub fn as_i128(&self) -> Result { + let signum = self + .signum + .map_or(1i128, |is_positive| if is_positive { 1 } else { -1 }); + let integer = self.integer()?; + let decimal_numerator = self + .decimal + .as_ref() + .map(|d| d.as_u64()) + .transpose()? + .unwrap_or(0); + let decimal_places: u32 = self + .decimal + .as_ref() + .map(|d| d.places.len()) + .unwrap_or(0) + .try_into() + .context(IntegerSnafu)?; + let exp: u32 = self + .exponent + .as_ref() + .and_then(|(_, maybe_exp)| maybe_exp.as_ref().map(|digits| digits.as_u64())) + .transpose()? + .unwrap_or(0) + .try_into() + .context(IntegerSnafu)?; + let multiplier = 10i128.checked_pow(exp).context(LargeExponentSnafu { + expected: u128::MAX.ilog10() as usize, + seen: exp as u64, + })?; + // The exponent must be >= the number of decimal places to yield an integer result. + let decimal_exp = exp + .checked_sub(decimal_places) + .with_context(|| SmallExponentSnafu { + expected: decimal_places as usize, + seen: exp, + }); + // Since it's hard to see through all the function chaining, this is + // what's going on here: + // ``` + // signum * ( + // (integer * 10^exp) + + // (decimal_numerator * 10^(exp - decimal_places)) + // ) + // ``` + let multiplied_integer = (integer as i128) + .checked_mul(multiplier) + .with_context(|| OverflowSnafu)?; + let decimal_multiplier = match decimal_exp { + Ok(d) => 10i128.checked_pow(d).context(OverflowSnafu)?, + Err(_) if decimal_numerator == 0 => 0, + Err(e) => return Err(e), + }; + let multiplied_decimal = (decimal_numerator as i128) + .checked_mul(decimal_multiplier) + .with_context(|| OverflowSnafu)?; + let value = multiplied_integer + .checked_add(multiplied_decimal) + .context(OverflowSnafu)?; + signum.checked_mul(value).context(OverflowSnafu) + } + + /// Convert this [`Number`] into a [`U256`], if possible. + /// + /// ## Errors + /// - Returns [`NegativeValueForUnsignedType`](ValidationError::NegativeValueForUnsignedType) + /// if the number has a negative sign + /// - Returns [`Overflow`](ValidationError::Overflow) if internal arithmetic operations overflow + /// - Returns [`SmallExponent`](ValidationError::SmallExponent) if the exponent doesn't cover + /// all non-zero decimal places (i.e., would require truncating non-zero decimal digits) + pub fn as_uint256(&self) -> Result { + // Reject negative numbers - U256 is unsigned + snafu::ensure!( + self.signum.is_none_or(|b| b), + NegativeValueForUnsignedTypeSnafu + ); + + let integer = self.integer.as_uint256()?; + let decimal_numerator = self + .decimal + .as_ref() + .map(|d| d.as_uint256()) + .transpose()? + .unwrap_or(U256::zero()); + let decimal_places: u32 = self + .decimal + .as_ref() + .map(|d| d.places.len()) + .unwrap_or(0) + .try_into() + .context(IntegerSnafu)?; + let exp: u32 = self + .exponent + .as_ref() + .and_then(|(_, maybe_exp)| maybe_exp.as_ref().map(|digits| digits.as_u64())) + .transpose()? + .unwrap_or(0) + .try_into() + .context(IntegerSnafu)?; + + let multiplier = U256::from(10u64) + .checked_pow(U256::from(exp)) + .context(OverflowSnafu)?; + + // The exponent must be >= the number of decimal places to yield an integer result. + let decimal_exp = exp + .checked_sub(decimal_places) + .with_context(|| SmallExponentSnafu { + expected: decimal_places as usize, + seen: exp, + }); + + // Formula: (integer * 10^exp) + (decimal_numerator * 10^(exp - decimal_places)) + let multiplied_integer = integer.checked_mul(multiplier).context(OverflowSnafu)?; + + let decimal_multiplier = match decimal_exp { + Ok(d) => U256::from(10u64) + .checked_pow(U256::from(d)) + .context(OverflowSnafu)?, + Err(_) if decimal_numerator.is_zero() => U256::zero(), + Err(e) => return Err(e), + }; + + let multiplied_decimal = decimal_numerator + .checked_mul(decimal_multiplier) + .context(OverflowSnafu)?; + + multiplied_integer + .checked_add(multiplied_decimal) + .context(OverflowSnafu) + } +} + +/// Ethereum Name Service name. +/// +/// See [EIP-137](https://eips.ethereum.org/EIPS/eip-137). +/// +/// Examples: +/// * dao.eth +/// * linea.eth +/// +/// Uses: +/// * +/// +/// ENS names must conform to the following syntax: +/// +/// ## Name Syntax +/// +/// ```not_abnf +/// ::=